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.
Files changed (5) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +96 -53
  3. package/backend.ts +497 -10
  4. package/bro.ts +778 -160
  5. 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 is the Agy-only implementation of docs/plans/2026-09-22-shared-backend-design.md: it owns
9
- // Agy CLI selection, process invocation, progress/outcome normalization, continuation, and
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
- return processError.code === "ENOENT"
66
- ? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
67
- : `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
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(`Agy exited unexpectedly${exitSignal ? ` (signal ${exitSignal})` : ""}, not from a request Bro made.`);
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: AgySelection,
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
  }