pi-bro 0.15.1 → 0.17.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 +24 -0
- package/README.md +96 -53
- package/backend.ts +497 -10
- package/bro.ts +778 -160
- package/package.json +2 -2
package/backend.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
|
|
7
7
|
// Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
|
|
8
|
-
// This
|
|
9
|
-
//
|
|
8
|
+
// This implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
|
|
9
|
+
// Claude Code CLI (explain/show/advisor; no btw yet) and the Grok CLI (all features): it owns CLI selection, process invocation, progress/outcome normalization, continuation, and
|
|
10
10
|
// single-attempt cleanup. Feature code (bro.ts) keeps retries, UI, source/session capture and
|
|
11
11
|
// settings.
|
|
12
12
|
|
|
13
13
|
export type BackendFeature = "explain" | "show" | "btw" | "advisor";
|
|
14
14
|
export type BackendAccess = "restricted" | "workspace-full";
|
|
15
15
|
export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
|
|
16
|
+
export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
export const GROK_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
|
|
18
|
+
export type BackendSelection =
|
|
19
|
+
| ({ backend?: "agy" } & AgySelection)
|
|
20
|
+
| { backend: "claude"; model: string; effort?: (typeof CLAUDE_EFFORTS)[number] }
|
|
21
|
+
| { backend: "grok"; model: string; effort?: (typeof GROK_EFFORTS)[number] };
|
|
16
22
|
export type BackendContinuation = { id: string };
|
|
17
23
|
export type BackendProgress = { kind: "text"; text: string } | { kind: "activity"; label: string; timestamp: number };
|
|
18
24
|
export type BackendOnProgress = (progress: BackendProgress) => void;
|
|
@@ -61,14 +67,19 @@ export function agySelection(pair: { model: string; effort: "default" | "low" |
|
|
|
61
67
|
};
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
function processStartMessage(processError: NodeJS.ErrnoException): string {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
function processStartMessage(processError: NodeJS.ErrnoException, cli = "Agy"): string {
|
|
71
|
+
if (processError.code === "ENOENT") {
|
|
72
|
+
return cli === "Agy"
|
|
73
|
+
? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
|
|
74
|
+
: cli === "Grok"
|
|
75
|
+
? "Grok could not start. Make sure `grok` is installed and on PATH, then run `/bro doctor`."
|
|
76
|
+
: "Claude Code could not start. Make sure `claude` is installed and on PATH, then run `/bro doctor`.";
|
|
77
|
+
}
|
|
78
|
+
return `${cli} could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null): string {
|
|
71
|
-
return withDoctor(
|
|
81
|
+
function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null, cli = "Agy"): string {
|
|
82
|
+
return withDoctor(`${cli} exited unexpectedly${exitSignal ? ` (signal ${exitSignal})` : ""}, not from a request Bro made.`);
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
// Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
|
|
@@ -481,6 +492,458 @@ async function executeAdvisorStdin(
|
|
|
481
492
|
return { status: "success", text };
|
|
482
493
|
}
|
|
483
494
|
|
|
495
|
+
|
|
496
|
+
// Claude Code supports every feature except btw (it has no continuation wiring yet). Grok supports
|
|
497
|
+
// every feature, but its "restricted" access is a prompt instruction, not enforcement (see
|
|
498
|
+
// GROK_RESTRICTED_PREFIX).
|
|
499
|
+
export function backendSupports(backend: "agy" | "claude" | "grok", feature: BackendFeature): boolean {
|
|
500
|
+
return backend === "agy" || backend === "grok" || feature !== "btw";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
type ClaudeEvent = {
|
|
504
|
+
type?: unknown;
|
|
505
|
+
subtype?: unknown;
|
|
506
|
+
is_error?: unknown;
|
|
507
|
+
result?: unknown;
|
|
508
|
+
parent_tool_use_id?: unknown;
|
|
509
|
+
stop_reason?: unknown;
|
|
510
|
+
terminal_reason?: unknown;
|
|
511
|
+
event?: { type?: unknown; delta?: { type?: unknown; text?: unknown } };
|
|
512
|
+
message?: { content?: unknown };
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Every Claude run is fresh (never resumed, nothing persisted) under --safe-mode, which disables
|
|
516
|
+
// CLAUDE.md, skills, plugins, hooks and MCP servers but keeps the user's auth (--bare would break
|
|
517
|
+
// OAuth). The prompt goes over stdin. explain/show additionally run tool-less in a scratch cwd;
|
|
518
|
+
// advisor runs in the caller's workspace with tools auto-approved. Only the terminal `result`
|
|
519
|
+
// event is authoritative; streamed text_delta events are progress only, and thinking is dropped.
|
|
520
|
+
async function executeClaude(
|
|
521
|
+
request: BackendRequest,
|
|
522
|
+
selection: { model: string; effort?: string },
|
|
523
|
+
signal: AbortSignal,
|
|
524
|
+
onProgress: BackendOnProgress | undefined,
|
|
525
|
+
killEscalationMs: number,
|
|
526
|
+
deadlineMsOverride: number | undefined,
|
|
527
|
+
): Promise<BackendOutcome> {
|
|
528
|
+
const isAdvisor = request.feature === "advisor";
|
|
529
|
+
const deadlineMs = deadlineMsOverride ?? (isAdvisor ? 610_000 : 125_000);
|
|
530
|
+
const action = isAdvisor ? "complete the advisor consultation" : "simplify the response";
|
|
531
|
+
|
|
532
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
533
|
+
const runDirectory = isAdvisor ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
534
|
+
if (signal.aborted) {
|
|
535
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
536
|
+
return { status: "cancelled", message: "Canceled." };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
const child = spawn(
|
|
541
|
+
"claude",
|
|
542
|
+
[
|
|
543
|
+
"-p",
|
|
544
|
+
"--safe-mode",
|
|
545
|
+
"--no-session-persistence",
|
|
546
|
+
"--disable-slash-commands",
|
|
547
|
+
"--output-format",
|
|
548
|
+
"stream-json",
|
|
549
|
+
"--verbose",
|
|
550
|
+
"--include-partial-messages",
|
|
551
|
+
...(isAdvisor
|
|
552
|
+
? ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--dangerously-skip-permissions"]
|
|
553
|
+
: ["--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]),
|
|
554
|
+
"--model",
|
|
555
|
+
selection.model,
|
|
556
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
557
|
+
],
|
|
558
|
+
{
|
|
559
|
+
cwd: isAdvisor ? request.cwd : runDirectory,
|
|
560
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
561
|
+
windowsHide: true,
|
|
562
|
+
detached: process.platform !== "win32",
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
567
|
+
let processError: Error | undefined;
|
|
568
|
+
let stderr = "";
|
|
569
|
+
let partial = "";
|
|
570
|
+
let final: string | undefined;
|
|
571
|
+
let terminalError: string | undefined;
|
|
572
|
+
let protocolError: string | undefined;
|
|
573
|
+
let stdoutBuffer = "";
|
|
574
|
+
|
|
575
|
+
child.stderr?.setEncoding("utf8");
|
|
576
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
577
|
+
stderr += chunk;
|
|
578
|
+
});
|
|
579
|
+
child.once("error", (error) => {
|
|
580
|
+
processError = error;
|
|
581
|
+
});
|
|
582
|
+
child.stdin?.on("error", () => {
|
|
583
|
+
// claude exiting before it reads stdin is reported through the close/error path below.
|
|
584
|
+
});
|
|
585
|
+
child.stdin?.end(request.prompt);
|
|
586
|
+
|
|
587
|
+
const handleLine = (line: string) => {
|
|
588
|
+
if (!line.trim() || attempt.causeOf()) return;
|
|
589
|
+
let event: ClaudeEvent;
|
|
590
|
+
try {
|
|
591
|
+
event = JSON.parse(line) as ClaudeEvent;
|
|
592
|
+
} catch {
|
|
593
|
+
throw new Error("Claude emitted invalid stream-json output.");
|
|
594
|
+
}
|
|
595
|
+
const topLevel = event.parent_tool_use_id === undefined || event.parent_tool_use_id === null;
|
|
596
|
+
if (!isAdvisor && topLevel && event.type === "stream_event" && event.event?.type === "content_block_delta") {
|
|
597
|
+
const delta = event.event.delta;
|
|
598
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
|
|
599
|
+
partial += delta.text;
|
|
600
|
+
onProgress?.({ kind: "text", text: partial });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (isAdvisor && topLevel && event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
604
|
+
for (const block of event.message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
|
|
605
|
+
const label =
|
|
606
|
+
block?.type === "tool_use" && typeof block.name === "string"
|
|
607
|
+
? block.name.trim()
|
|
608
|
+
: block?.type === "text" && typeof block.text === "string"
|
|
609
|
+
? block.text.split("\n").find((text) => text.trim())?.trim()
|
|
610
|
+
: undefined;
|
|
611
|
+
if (label) onProgress?.({ kind: "activity", label, timestamp: Date.now() });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (event.type !== "result" || !topLevel) return;
|
|
615
|
+
if (event.subtype === "success" && event.is_error === false && (event.stop_reason !== "end_turn" || (event.terminal_reason !== undefined && event.terminal_reason !== "completed"))) {
|
|
616
|
+
terminalError ??= `Claude did not complete the answer (stop reason: ${String(event.stop_reason)}, terminal reason: ${String(event.terminal_reason)}).`;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
// A result ends a model turn, not necessarily the stream; the first failure is latched and
|
|
620
|
+
// otherwise the latest successful result wins once the process exits cleanly.
|
|
621
|
+
if (event.subtype === "success" && event.is_error === false && typeof event.result === "string") {
|
|
622
|
+
final = event.result;
|
|
623
|
+
} else {
|
|
624
|
+
const detail =
|
|
625
|
+
typeof event.subtype === "string" && event.subtype !== "success"
|
|
626
|
+
? event.subtype
|
|
627
|
+
: typeof event.result === "string" && event.result.trim()
|
|
628
|
+
? event.result.trim()
|
|
629
|
+
: "turn failed";
|
|
630
|
+
terminalError ??= `Claude failed: ${detail}`;
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
child.stdout?.setEncoding("utf8");
|
|
635
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
636
|
+
stdoutBuffer += chunk;
|
|
637
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
638
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
639
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
640
|
+
protocolError ??= `Claude emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
641
|
+
stdoutBuffer = "";
|
|
642
|
+
attempt.stop("protocol");
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
for (const line of parts) {
|
|
646
|
+
try {
|
|
647
|
+
handleLine(line);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
protocolError ??= errorMessage(error);
|
|
650
|
+
attempt.stop("protocol");
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const { code, exitSignal } = await attempt.closed;
|
|
657
|
+
attempt.dispose();
|
|
658
|
+
if (stdoutBuffer.trim()) {
|
|
659
|
+
try {
|
|
660
|
+
handleLine(stdoutBuffer);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
protocolError ??= errorMessage(error);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const partialText = partial || undefined;
|
|
667
|
+
const cause = attempt.causeOf();
|
|
668
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText };
|
|
669
|
+
if (cause === "timeout") {
|
|
670
|
+
const during = isAdvisor ? "during the advisor consultation" : "while simplifying the response";
|
|
671
|
+
return { status: "timeout", message: `Claude timed out ${during}. Run \`/bro doctor\` for setup help.`, partialText };
|
|
672
|
+
}
|
|
673
|
+
if (protocolError) return { status: "failure", message: withDoctor(protocolError), partialText };
|
|
674
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException, "Claude") };
|
|
675
|
+
if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal, "Claude"), partialText };
|
|
676
|
+
if (terminalError) return { status: "failure", message: withDoctor(terminalError), partialText };
|
|
677
|
+
if (code !== 0) {
|
|
678
|
+
const detail = stderr.trim();
|
|
679
|
+
return {
|
|
680
|
+
status: "failure",
|
|
681
|
+
message: detail
|
|
682
|
+
? `Claude could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
683
|
+
: `Claude could not ${action}. Make sure Claude Code is installed and signed in, then run \`/bro doctor\`.`,
|
|
684
|
+
partialText,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
if (final === undefined) {
|
|
688
|
+
return { status: "failure", message: withDoctor(`Claude exited without a result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), partialText };
|
|
689
|
+
}
|
|
690
|
+
const text = final.trim();
|
|
691
|
+
if (!text) return { status: "failure", message: withDoctor("Claude returned no final answer."), partialText };
|
|
692
|
+
return { status: "success", text };
|
|
693
|
+
} finally {
|
|
694
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
type GrokEvent = {
|
|
699
|
+
permissionMode?: unknown;
|
|
700
|
+
type?: unknown;
|
|
701
|
+
subtype?: unknown;
|
|
702
|
+
is_error?: unknown;
|
|
703
|
+
result?: unknown;
|
|
704
|
+
errors?: unknown;
|
|
705
|
+
message?: unknown;
|
|
706
|
+
parent_tool_use_id?: unknown;
|
|
707
|
+
stop_reason?: unknown;
|
|
708
|
+
session_id?: unknown;
|
|
709
|
+
event?: { type?: unknown; delta?: { type?: unknown; text?: unknown } };
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// Grok's "restricted" access is a request in the prompt, NOT technical enforcement: every Grok run
|
|
713
|
+
// has all tools on (--sandbox off, bypassPermissions, subagents/web/scheduler available), so the
|
|
714
|
+
// model can still read, write or reach out if it ignores this. The parent UI owns telling the user
|
|
715
|
+
// that truthfully.
|
|
716
|
+
export const GROK_RESTRICTED_PREFIX =
|
|
717
|
+
"Bro restricted mode (a request, not a technical restriction): answer only from the context supplied in this prompt. " +
|
|
718
|
+
"Do not inspect, read, or write workspace files, run commands, or use web or other external tools unless the user explicitly asks you to in this prompt.";
|
|
719
|
+
|
|
720
|
+
// Grok runs every feature with all capabilities on (--sandbox off, bypassPermissions, no tool
|
|
721
|
+
// blacklist). explain/show run in a fresh mkdtemp scratch cwd removed afterwards (the Agy pattern);
|
|
722
|
+
// btw (both accesses) runs in the caller's workspace cwd and continues natively with --resume, whose
|
|
723
|
+
// session keeps the cwd it was created in; the advisor runs fresh in the workspace. Restricted
|
|
724
|
+
// requests get GROK_RESTRICTED_PREFIX. The prompt goes through a private 0600 file in its own
|
|
725
|
+
// mkdtemp directory, removed once the attempt ends.
|
|
726
|
+
// Only a top-level terminal `result` with success/is_error false/end_turn is authoritative; the
|
|
727
|
+
// first failure (a failed result or a top-level `error` event, even after a success) is latched.
|
|
728
|
+
// Nested (subagent) frames are ignored. The advisor reports activity labels only; explain/show/btw
|
|
729
|
+
// report top-level text_delta progress, falling back to an assistant message's text blocks only when
|
|
730
|
+
// no delta streamed for it. Thinking is never reported. btw requires init/result session ids to
|
|
731
|
+
// agree (and to equal the resumed id) and returns that id as the continuation.
|
|
732
|
+
async function executeGrok(
|
|
733
|
+
request: BackendRequest,
|
|
734
|
+
selection: { model: string; effort?: string },
|
|
735
|
+
signal: AbortSignal,
|
|
736
|
+
onProgress: BackendOnProgress | undefined,
|
|
737
|
+
killEscalationMs: number,
|
|
738
|
+
deadlineMsOverride: number | undefined,
|
|
739
|
+
): Promise<BackendOutcome> {
|
|
740
|
+
const isAdvisor = request.feature === "advisor";
|
|
741
|
+
const isBtw = request.feature === "btw";
|
|
742
|
+
const deadlineMs = deadlineMsOverride ?? (isAdvisor || (isBtw && request.access === "workspace-full") ? 610_000 : isBtw ? 130_000 : 125_000);
|
|
743
|
+
const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
|
|
744
|
+
const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
|
|
745
|
+
const emptyTextMessage = isAdvisor ? "Grok returned no advice." : isBtw ? "Grok returned no answer for the side question." : "Grok returned no final explanation.";
|
|
746
|
+
const prompt = request.access === "restricted" ? `${GROK_RESTRICTED_PREFIX}\n\n${request.prompt}` : request.prompt;
|
|
747
|
+
|
|
748
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
749
|
+
const promptDirectory = await mkdtemp(join(tmpdir(), "pi-bro-grok-"));
|
|
750
|
+
let runDirectory: string | undefined;
|
|
751
|
+
try {
|
|
752
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
753
|
+
const promptFile = join(promptDirectory, "prompt.txt");
|
|
754
|
+
await writeFile(promptFile, prompt, { encoding: "utf8", mode: 0o600 });
|
|
755
|
+
if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
756
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
757
|
+
|
|
758
|
+
const child = spawn(
|
|
759
|
+
"grok",
|
|
760
|
+
[
|
|
761
|
+
"--sandbox",
|
|
762
|
+
"off",
|
|
763
|
+
"--permission-mode",
|
|
764
|
+
"bypassPermissions",
|
|
765
|
+
"--output-format",
|
|
766
|
+
"streaming-messages-json",
|
|
767
|
+
"--include-partial-messages",
|
|
768
|
+
"--model", selection.model,
|
|
769
|
+
...(selection.effort ? ["--reasoning-effort", selection.effort] : []),
|
|
770
|
+
...(isBtw && request.continuation ? ["--resume", request.continuation.id] : []),
|
|
771
|
+
"--prompt-file",
|
|
772
|
+
promptFile,
|
|
773
|
+
],
|
|
774
|
+
{ cwd: runDirectory ?? request.cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32" },
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
778
|
+
let processError: Error | undefined;
|
|
779
|
+
let stderr = "";
|
|
780
|
+
let partial = "";
|
|
781
|
+
let streamedSinceAssistant = false;
|
|
782
|
+
let initSessionId: string | undefined;
|
|
783
|
+
let resultSessionId: string | undefined;
|
|
784
|
+
let final: string | undefined;
|
|
785
|
+
let terminalError: string | undefined;
|
|
786
|
+
let protocolError: string | undefined;
|
|
787
|
+
let stdoutBuffer = "";
|
|
788
|
+
|
|
789
|
+
child.stderr?.setEncoding("utf8");
|
|
790
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
791
|
+
stderr += chunk;
|
|
792
|
+
});
|
|
793
|
+
child.once("error", (error) => {
|
|
794
|
+
processError = error;
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const handleLine = (line: string) => {
|
|
798
|
+
if (!line.trim() || attempt.causeOf()) return;
|
|
799
|
+
let event: GrokEvent;
|
|
800
|
+
try {
|
|
801
|
+
event = JSON.parse(line) as GrokEvent;
|
|
802
|
+
} catch {
|
|
803
|
+
throw new Error("Grok emitted invalid streaming-messages-json output.");
|
|
804
|
+
}
|
|
805
|
+
if (event.parent_tool_use_id !== undefined && event.parent_tool_use_id !== null) return;
|
|
806
|
+
if (event.type === "system" && event.subtype === "init") {
|
|
807
|
+
if (event.permissionMode !== "bypassPermissions") {
|
|
808
|
+
throw new Error("Grok did not apply the required bypassPermissions mode; check Grok policy/configuration.");
|
|
809
|
+
}
|
|
810
|
+
if (typeof event.session_id === "string") initSessionId = event.session_id;
|
|
811
|
+
}
|
|
812
|
+
if (!isAdvisor && event.type === "stream_event" && event.event?.type === "content_block_delta") {
|
|
813
|
+
const delta = event.event.delta;
|
|
814
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
|
|
815
|
+
partial += delta.text;
|
|
816
|
+
streamedSinceAssistant = true;
|
|
817
|
+
onProgress?.({ kind: "text", text: partial });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (event.type === "error") {
|
|
821
|
+
terminalError ??= `Grok error: ${typeof event.message === "string" && event.message.trim() ? event.message.trim() : "unknown error"}`;
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const message = event.message as { content?: unknown } | undefined;
|
|
825
|
+
if (!isAdvisor && event.type === "assistant" && Array.isArray(message?.content)) {
|
|
826
|
+
// Fallback only: an assistant message whose text already streamed as deltas is not re-appended.
|
|
827
|
+
if (!streamedSinceAssistant) {
|
|
828
|
+
const text = (message.content as Array<{ type?: unknown; text?: unknown }>)
|
|
829
|
+
.map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : ""))
|
|
830
|
+
.join("");
|
|
831
|
+
if (text) {
|
|
832
|
+
partial += text;
|
|
833
|
+
onProgress?.({ kind: "text", text: partial });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
streamedSinceAssistant = false;
|
|
837
|
+
}
|
|
838
|
+
if (isAdvisor && event.type === "assistant" && Array.isArray(message?.content)) {
|
|
839
|
+
for (const block of message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
|
|
840
|
+
const label =
|
|
841
|
+
block?.type === "tool_use" && typeof block.name === "string"
|
|
842
|
+
? block.name.trim()
|
|
843
|
+
: block?.type === "text" && typeof block.text === "string"
|
|
844
|
+
? block.text.split("\n").find((text) => text.trim())?.trim()
|
|
845
|
+
: undefined;
|
|
846
|
+
if (label) onProgress?.({ kind: "activity", label, timestamp: Date.now() });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (event.type !== "result") return;
|
|
850
|
+
if (typeof event.session_id === "string") resultSessionId = event.session_id;
|
|
851
|
+
if (event.subtype === "success" && event.is_error === false) {
|
|
852
|
+
if (event.stop_reason !== "end_turn") {
|
|
853
|
+
terminalError ??= `Grok did not complete the ${isAdvisor ? "advice" : "answer"} (stop reason: ${String(event.stop_reason)}).`;
|
|
854
|
+
} else if (typeof event.result === "string") {
|
|
855
|
+
final = event.result;
|
|
856
|
+
} else {
|
|
857
|
+
terminalError ??= "Grok reported success without a final result.";
|
|
858
|
+
}
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const firstError = Array.isArray(event.errors)
|
|
862
|
+
? event.errors.map((item) => (typeof item === "string" ? item : (item as { message?: unknown })?.message)).find((item) => typeof item === "string" && item.trim())
|
|
863
|
+
: undefined;
|
|
864
|
+
const detail =
|
|
865
|
+
typeof firstError === "string"
|
|
866
|
+
? firstError.trim()
|
|
867
|
+
: typeof event.subtype === "string" && event.subtype !== "success"
|
|
868
|
+
? event.subtype
|
|
869
|
+
: typeof event.result === "string" && event.result.trim()
|
|
870
|
+
? event.result.trim()
|
|
871
|
+
: "turn failed";
|
|
872
|
+
terminalError ??= `Grok failed: ${detail}`;
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
child.stdout?.setEncoding("utf8");
|
|
876
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
877
|
+
stdoutBuffer += chunk;
|
|
878
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
879
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
880
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
881
|
+
protocolError ??= `Grok emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
882
|
+
stdoutBuffer = "";
|
|
883
|
+
attempt.stop("protocol");
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
for (const line of parts) {
|
|
887
|
+
try {
|
|
888
|
+
handleLine(line);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
protocolError ??= errorMessage(error);
|
|
891
|
+
attempt.stop("protocol");
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const { code, exitSignal } = await attempt.closed;
|
|
898
|
+
attempt.dispose();
|
|
899
|
+
if (stdoutBuffer.trim()) {
|
|
900
|
+
try {
|
|
901
|
+
handleLine(stdoutBuffer);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
protocolError ??= errorMessage(error);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const partialText = partial ? { partialText: partial } : {};
|
|
908
|
+
const cause = attempt.causeOf();
|
|
909
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", ...partialText };
|
|
910
|
+
if (cause === "timeout") return { status: "timeout", message: `Grok timed out ${timeoutVerb}. Run \`/bro doctor\` for setup help.`, ...partialText };
|
|
911
|
+
if (protocolError) return { status: "failure", message: withDoctor(protocolError), ...partialText };
|
|
912
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException, "Grok") };
|
|
913
|
+
if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal, "Grok"), ...partialText };
|
|
914
|
+
if (terminalError) return { status: "failure", message: withDoctor(terminalError), ...partialText };
|
|
915
|
+
if (code !== 0) {
|
|
916
|
+
const detail = stderr.trim();
|
|
917
|
+
return {
|
|
918
|
+
status: "failure",
|
|
919
|
+
message: detail
|
|
920
|
+
? `Grok could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
921
|
+
: `Grok could not ${action}. Make sure \`grok\` is installed and signed in, then run \`/bro doctor\`.`,
|
|
922
|
+
...partialText,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
if (final === undefined) {
|
|
926
|
+
return { status: "failure", message: withDoctor(`Grok exited without a terminal result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), ...partialText };
|
|
927
|
+
}
|
|
928
|
+
const text = final.trim();
|
|
929
|
+
if (!text) return { status: "failure", message: withDoctor(emptyTextMessage), ...partialText };
|
|
930
|
+
if (!isBtw) return { status: "success", text };
|
|
931
|
+
// btw continuation: the session id must be reported, consistent, and (on resume) unchanged.
|
|
932
|
+
const sessionId = resultSessionId ?? initSessionId;
|
|
933
|
+
if (!sessionId || (initSessionId !== undefined && initSessionId !== sessionId) || (request.continuation && sessionId !== request.continuation.id)) {
|
|
934
|
+
return {
|
|
935
|
+
status: "failure",
|
|
936
|
+
message: withDoctor(`Grok reported an inconsistent session id (expected ${request.continuation?.id ?? "one id"}, init ${initSessionId ?? "none"}, result ${resultSessionId ?? "none"}).`),
|
|
937
|
+
...partialText,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
return { status: "success", text, continuation: { id: sessionId } };
|
|
941
|
+
} finally {
|
|
942
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
943
|
+
await rm(promptDirectory, { recursive: true, force: true });
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
484
947
|
// Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
|
|
485
948
|
// e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
|
|
486
949
|
// host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
|
|
@@ -488,7 +951,7 @@ async function executeAdvisorStdin(
|
|
|
488
951
|
// callers never override the deadline or kill-escalation delay.
|
|
489
952
|
export async function execute(
|
|
490
953
|
request: BackendRequest,
|
|
491
|
-
selection:
|
|
954
|
+
selection: BackendSelection,
|
|
492
955
|
signal: AbortSignal,
|
|
493
956
|
onProgress?: BackendOnProgress,
|
|
494
957
|
options?: BackendExecuteOptions,
|
|
@@ -499,7 +962,31 @@ export async function execute(
|
|
|
499
962
|
((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
|
|
500
963
|
(request.feature !== "btw" && request.continuation)
|
|
501
964
|
) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
|
|
965
|
+
const backend = (selection as { backend?: unknown }).backend;
|
|
966
|
+
// An explicit tag guard: a stale or corrupt runtime tag must fail, never fall through to Agy.
|
|
967
|
+
if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok") {
|
|
968
|
+
return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude or Grok in \`/bro config\`.` };
|
|
969
|
+
}
|
|
502
970
|
const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
|
|
971
|
+
if (selection.backend === "grok") {
|
|
972
|
+
// Grok btw always runs (and resumes) in the caller's workspace, even restricted.
|
|
973
|
+
if (request.feature === "btw" && !request.cwd?.trim()) {
|
|
974
|
+
return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
|
|
975
|
+
}
|
|
976
|
+
if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !GROK_EFFORTS.includes(selection.effort))) {
|
|
977
|
+
return { status: "failure", message: "Unsupported Grok selection: check the model and effort (low, medium, high or xhigh)." };
|
|
978
|
+
}
|
|
979
|
+
return executeGrok(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
980
|
+
}
|
|
981
|
+
if (selection.backend === "claude") {
|
|
982
|
+
if (!backendSupports("claude", request.feature)) {
|
|
983
|
+
return { status: "failure", message: "Claude does not support /bro btw yet; switch btw back to Agy in `/bro config`." };
|
|
984
|
+
}
|
|
985
|
+
if (!selection.model.trim() || (selection.effort !== undefined && !CLAUDE_EFFORTS.includes(selection.effort))) {
|
|
986
|
+
return { status: "failure", message: "Unsupported Claude selection: check the model and effort (low, medium, high, xhigh or max)." };
|
|
987
|
+
}
|
|
988
|
+
return executeClaude(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
989
|
+
}
|
|
503
990
|
if (request.feature === "advisor") {
|
|
504
991
|
return executeAdvisorStdin(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
505
992
|
}
|