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