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/CHANGELOG.md +71 -0
- package/README.md +140 -64
- package/backend.ts +158 -232
- package/bro.ts +411 -1421
- package/config-ui.ts +325 -0
- package/docs/README.md +2 -0
- package/docs/guided-review.md +131 -0
- package/docs/pig-compatibility.md +1 -0
- package/package.json +13 -4
- package/prompt.ts +65 -11
- package/review-modal.ts +1272 -0
- package/review-source.ts +296 -0
- package/review-ui.ts +519 -0
- package/review.ts +586 -0
- package/settings.ts +505 -0
- package/sources.ts +367 -0
- package/util.ts +26 -0
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
|
|
8
|
-
// This implements docs/plans/2026-09-22-shared-backend-design.md
|
|
9
|
-
// Claude Code
|
|
10
|
-
// single-attempt cleanup. Feature code
|
|
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
|
|
95
|
-
function
|
|
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
|
|
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 =
|
|
132
|
-
|
|
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
|
-
|
|
144
|
+
if (isClosed) return;
|
|
145
|
+
killProcessGroup(child, "SIGTERM");
|
|
139
146
|
killTimer = setTimeout(() => {
|
|
140
|
-
|
|
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 =
|
|
229
|
-
const deadlineMs = deadlineMsOverride ?? (
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
}
|
|
322
|
-
|
|
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) --
|
|
352
|
-
const
|
|
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
|
|
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 =
|
|
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
|
-
...(
|
|
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
|
|
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 ?? (
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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))) {
|