pi-bro 0.21.0 → 0.22.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/backend.ts CHANGED
@@ -3,14 +3,17 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- // Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
7
- // This implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
8
- // Claude Code CLI (all features) and the Grok CLI (all features): it owns CLI selection, process invocation, progress/outcome normalization, continuation, and
9
- // single-attempt cleanup. Feature code (bro.ts) keeps retries, UI, source/session capture and
10
- // 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.
11
11
 
12
- export type BackendFeature = "explain" | "show" | "btw" | "advisor";
12
+ export type BackendFeature = "explain" | "show" | "btw" | "advisor" | "review";
13
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
+ }
14
17
  export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
15
18
  export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
16
19
  export const GROK_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
@@ -90,8 +93,8 @@ function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null, cli = "Agy")
90
93
  }
91
94
 
92
95
  // Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
93
- // just the immediate agy process -- child.kill() alone only ever reaches the immediate child.
94
- 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 {
95
98
  if (process.platform !== "win32" && typeof child.pid === "number") {
96
99
  try {
97
100
  process.kill(-child.pid, signalName);
@@ -104,7 +107,7 @@ function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
104
107
  }
105
108
 
106
109
  // The three causes that stop an in-flight attempt: user cancellation, the host-imposed deadline,
107
- // 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
108
111
  // to occur -- and it is never relabeled by a later signal (e.g. a cancel arriving after a deadline
109
112
  // already fired stays a timeout, not a cancellation).
110
113
  type StopCause = "cancelled" | "timeout" | "protocol";
@@ -139,9 +142,9 @@ export function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineM
139
142
  if (cause) return; // latched: the first stop cause wins
140
143
  cause = next;
141
144
  if (isClosed) return;
142
- killAgyGroup(child, "SIGTERM");
145
+ killProcessGroup(child, "SIGTERM");
143
146
  killTimer = setTimeout(() => {
144
- killAgyGroup(child, "SIGKILL");
147
+ killProcessGroup(child, "SIGKILL");
145
148
  // Detached descendants (or Windows grandchildren) may retain inherited pipes.
146
149
  // Stop waiting on those pipes after escalation; never promote this stop to success.
147
150
  child.stdin?.destroy();
@@ -229,12 +232,12 @@ async function executeArgvPrint(
229
232
  // stdin protocol rather than risking Linux's per-argument byte limit.
230
233
  const stdinPrompt = Buffer.byteLength(request.prompt, "utf8") >= 120_000;
231
234
  const isBtw = request.feature === "btw";
232
- const full = isBtw && request.access === "workspace-full";
233
- 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);
234
237
  const printTimeout = full ? "10m" : "2m";
235
- const action = isBtw ? "answer the side question" : "simplify the response";
236
- const timeoutVerb = isBtw ? "during the side conversation" : "while simplifying the response";
237
- 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.";
238
241
 
239
242
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
240
243
 
@@ -257,7 +260,7 @@ async function executeArgvPrint(
257
260
  ...(selection.effort ? ["--effort", selection.effort] : []),
258
261
  "--print-timeout",
259
262
  printTimeout,
260
- ...(request.continuation ? ["--conversation", request.continuation.id] : []),
263
+ ...(isBtw && request.continuation ? ["--conversation", request.continuation.id] : []),
261
264
  ...(stdinPrompt ? ["--input-format", "stream-json"] : ["--print", request.prompt]),
262
265
  ],
263
266
  {
@@ -565,12 +568,13 @@ async function executeClaude(
565
568
  ): Promise<BackendOutcome> {
566
569
  const isAdvisor = request.feature === "advisor";
567
570
  const isBtw = request.feature === "btw";
571
+ const inspect = request.feature === "review";
568
572
  const full = request.access === "workspace-full";
569
- const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
570
- 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";
571
575
 
572
576
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
573
- const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
577
+ const runDirectory = full || isBtw || inspect ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
574
578
  if (signal.aborted) {
575
579
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
576
580
  return { status: "cancelled", message: "Canceled." };
@@ -588,7 +592,9 @@ async function executeClaude(
588
592
  "stream-json",
589
593
  "--verbose",
590
594
  "--include-partial-messages",
591
- ...(full
595
+ ...(inspect
596
+ ? ["--restricted", "--tools", "Read,Grep,Glob", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]
597
+ : full
592
598
  ? ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--dangerously-skip-permissions"]
593
599
  : ["--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]),
594
600
  ...(isBtw && request.continuation ? ["--resume", request.continuation.id] : []),
@@ -685,7 +691,7 @@ async function executeClaude(
685
691
  const cause = attempt.causeOf();
686
692
  if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText };
687
693
  if (cause === "timeout") {
688
- 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";
689
695
  return { status: "timeout", message: `Claude timed out ${during}. Run \`/bro doctor\` for setup help.`, partialText };
690
696
  }
691
697
  if (protocolError) return { status: "failure", message: withDoctor(protocolError), partialText };
@@ -706,7 +712,7 @@ async function executeClaude(
706
712
  return { status: "failure", message: withDoctor(`Claude exited without a result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), partialText };
707
713
  }
708
714
  const text = final.trim();
709
- 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 };
710
716
  if (!isBtw) return { status: "success", text };
711
717
  // btw continuation: the session id must be reported, consistent, and (on resume) unchanged.
712
718
  const sessionId = resultSessionId ?? initSessionId;
@@ -767,10 +773,10 @@ async function executeGrok(
767
773
  ): Promise<BackendOutcome> {
768
774
  const isAdvisor = request.feature === "advisor";
769
775
  const isBtw = request.feature === "btw";
770
- const deadlineMs = deadlineMsOverride ?? (isAdvisor || (isBtw && request.access === "workspace-full") ? 610_000 : isBtw ? 130_000 : 125_000);
771
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
772
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
773
- 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.";
774
780
  const prompt = request.access === "restricted" ? `${GROK_RESTRICTED_PREFIX}\n\n${request.prompt}` : request.prompt;
775
781
 
776
782
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
@@ -780,7 +786,7 @@ async function executeGrok(
780
786
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
781
787
  const promptFile = join(promptDirectory, "prompt.txt");
782
788
  await writeFile(promptFile, prompt, { encoding: "utf8", mode: 0o600 });
783
- if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
789
+ if (request.access !== "workspace-full" && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
784
790
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
785
791
 
786
792
  const child = spawn(
@@ -1003,12 +1009,12 @@ async function executeCodex(
1003
1009
  const isBtw = request.feature === "btw";
1004
1010
  const full = request.access === "workspace-full";
1005
1011
  const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1006
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1007
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1008
- 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.";
1009
1015
 
1010
1016
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1011
- const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
1017
+ const runDirectory = full || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
1012
1018
  if (signal.aborted) {
1013
1019
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
1014
1020
  return { status: "cancelled", message: "Canceled." };
@@ -1172,11 +1178,12 @@ async function executeMuse(
1172
1178
  ): Promise<BackendOutcome> {
1173
1179
  const isAdvisor = request.feature === "advisor";
1174
1180
  const isBtw = request.feature === "btw";
1181
+ const inspect = request.feature === "review";
1175
1182
  const full = request.access === "workspace-full";
1176
- const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1177
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1178
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1179
- 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.";
1180
1187
 
1181
1188
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1182
1189
  const promptDirectory = await mkdtemp(join(tmpdir(), "pi-bro-muse-"));
@@ -1186,7 +1193,7 @@ async function executeMuse(
1186
1193
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1187
1194
  const promptFile = join(promptDirectory, "prompt.txt");
1188
1195
  await writeFile(promptFile, request.prompt, { encoding: "utf8", mode: 0o600 });
1189
- if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1196
+ if (!full && !isBtw && !inspect) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1190
1197
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1191
1198
  const workspace = runDirectory ?? request.cwd!;
1192
1199
 
@@ -1197,6 +1204,7 @@ async function executeMuse(
1197
1204
  "--workspace", workspace,
1198
1205
  ...(!isBtw ? ["--no-session-log"] : []),
1199
1206
  ...(full ? ["--yolo"] : ["--disable-approval", "--disable-write", "--disable-shell"]),
1207
+ ...(inspect ? ["--disable-web-tools", "--no-foreign-personal-context"] : []),
1200
1208
  ...(isBtw && request.continuation ? ["--session-id", request.continuation.id] : []),
1201
1209
  "--model", selection.model,
1202
1210
  ...(selection.effort ? ["--reasoning-effort", selection.effort] : []),
@@ -1341,7 +1349,7 @@ async function executeMuse(
1341
1349
  }
1342
1350
  }
1343
1351
 
1344
- // 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,
1345
1353
  // e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
1346
1354
  // host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
1347
1355
  // after a bounded grace period) before resolving. `options` is for offline tests only -- production
@@ -1356,10 +1364,15 @@ export async function execute(
1356
1364
  if (
1357
1365
  (request.access === "workspace-full" && !request.cwd?.trim()) ||
1358
1366
  (request.feature === "advisor" && request.access !== "workspace-full") ||
1367
+ (request.feature === "review" && (request.access !== "restricted" || !request.cwd?.trim())) ||
1359
1368
  ((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
1360
1369
  (request.feature !== "btw" && request.continuation)
1361
1370
  ) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1362
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
+ }
1363
1376
  // An explicit tag guard: a stale or corrupt runtime tag must fail, never fall through to Agy.
1364
1377
  if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok" && backend !== "codex" && backend !== "muse") {
1365
1378
  return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude, Grok, Codex or Muse in \`/bro config\`.` };
@@ -1367,7 +1380,7 @@ export async function execute(
1367
1380
  const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
1368
1381
  if (selection.backend === "codex") {
1369
1382
  // Codex btw always runs (and resumes) in the caller's workspace, even restricted.
1370
- if (request.feature === "btw" && !request.cwd?.trim()) {
1383
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1371
1384
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1372
1385
  }
1373
1386
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !CODEX_EFFORTS.includes(selection.effort))) {
@@ -1377,7 +1390,7 @@ export async function execute(
1377
1390
  }
1378
1391
  if (selection.backend === "muse") {
1379
1392
  // Muse btw always runs (and resumes) in the caller's workspace, even restricted.
1380
- if (request.feature === "btw" && !request.cwd?.trim()) {
1393
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1381
1394
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1382
1395
  }
1383
1396
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !MUSE_EFFORTS.includes(selection.effort))) {
@@ -1387,7 +1400,7 @@ export async function execute(
1387
1400
  }
1388
1401
  if (selection.backend === "grok") {
1389
1402
  // Grok btw always runs (and resumes) in the caller's workspace, even restricted.
1390
- if (request.feature === "btw" && !request.cwd?.trim()) {
1403
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1391
1404
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1392
1405
  }
1393
1406
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !GROK_EFFORTS.includes(selection.effort))) {
@@ -1397,7 +1410,7 @@ export async function execute(
1397
1410
  }
1398
1411
  if (selection.backend === "claude") {
1399
1412
  // Claude btw always runs (and resumes) in the caller's workspace, even restricted.
1400
- if (request.feature === "btw" && !request.cwd?.trim()) {
1413
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1401
1414
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1402
1415
  }
1403
1416
  if (!selection.model.trim() || (selection.effort !== undefined && !CLAUDE_EFFORTS.includes(selection.effort))) {