pi-bro 0.16.0 → 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 +17 -0
  2. package/README.md +55 -27
  3. package/backend.ts +277 -7
  4. package/bro.ts +342 -115
  5. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.17.0] - 2026-09-24
6
+
7
+ ### Added
8
+
9
+ - Grok Build execution for explain, show, native multi-turn BTW (both modes), and advisor, with per-capability model/effort configuration, custom model IDs, and installation diagnostics. Agy remains the default.
10
+ - Capability-first Grok access: conversation-only intent uses prompt instructions when enforcement is unavailable, with truthful UI/docs rather than feature bans or speculative tool blacklists. Native tools remain available.
11
+ - Private temporary prompt files, backend-bound continuation, authoritative terminal completion checks and bounded cancellation. Backend/access changes start a fresh BTW thread instead of reusing incompatible native session IDs.
12
+ - Explain, show, and BTW modal headers show the model and reasoning effort each request used (`default` when the model's own effort applies); `/bro open` keeps the original label.
13
+
14
+ ### Changed
15
+
16
+ - The BTW header no longer shows a sandbox/conversation-only access label; the `full · edits repo` badge still marks `--full` threads.
17
+
18
+ ### Removed
19
+
20
+ - `/bro usage` is removed for now. Doctor still checks Agy account access.
21
+
5
22
  ## [0.16.0] - 2026-09-24
6
23
 
7
24
  ### Added
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # pi-bro
2
2
 
3
3
  Turn a dense AI reply, pasted text, local document, or public webpage into a
4
- plain-language explanation — or open a sandboxed side conversation with
4
+ plain-language explanation — or open a separate side conversation with
5
5
  `/bro btw` — without adding anything to your main agent's context.
6
6
 
7
7
  `pi-bro` is an extension for [Earendil Pi](https://github.com/earendil-works/pi).
@@ -70,7 +70,6 @@ text directly captures a new source the same way.
70
70
  | `/bro open` | Reopen the latest explanation without calling the simplifier again. |
71
71
  | `/bro show [n-turns] [query]` | Draw recent session turns (default last 1) as shapes instead of prose, from user and assistant conversation text only — tool calls, tool results, reasoning, and images are omitted. An optional query steers what the shapes focus on, with or without a leading turn count. |
72
72
  | `/bro doctor` | Check Bro's settings, Agy installation, account, model, effort, and mode. |
73
- | `/bro usage [--provider agy]` | Show current Agy resource limits. |
74
73
  | `/bro model [id]` | View or choose the selected backend’s shared default model. |
75
74
  | `/bro effort [low\|medium\|high]` | View or choose the shared default reasoning effort. |
76
75
  | `/bro mode [brief\|balanced\|faithful]` | View or choose the explanation mode. |
@@ -110,6 +109,10 @@ a persistent mode with `/bro mode`:
110
109
  - **O**: Open the HTML diagram when a show reply contains one
111
110
  - **Esc**: Close the modal, or cancel while Bro is working
112
111
 
112
+ The modal header shows the model and reasoning effort the explanation or
113
+ drawing used (`default` when the model's own effort applies); `/bro open`
114
+ keeps the original label.
115
+
113
116
  Bro temporarily captures mouse input while its modal is open. Native mouse
114
117
  selection may be unavailable or visually extend outside the modal depending on
115
118
  your terminal mode; press **C** to copy the complete explanation reliably.
@@ -117,17 +120,19 @@ your terminal mode; press **C** to copy the complete explanation reliably.
117
120
  ## Bro btw (side conversation)
118
121
 
119
122
  `/bro btw` opens a separate multi-turn conversation in a modal, so you can ask
120
- a quick side question while the main agent keeps working. It runs through Agy,
121
- the same backend as the rest of Bro, and never adds anything to Pi's
123
+ a quick side question while the main agent keeps working. It runs through the
124
+ selected Agy or Grok backend and never adds anything to Pi's
122
125
  conversation unless you explicitly insert it into the editor.
123
126
 
124
- - **Sandboxed by default**: the side conversation is read-only (no project
125
- access). Add `--full` to let it read and edit the workspace.
127
+ - **Conversation-only intent by default**: Agy uses its sandbox controls; Grok
128
+ receives prompt instructions to stay within supplied context, with normal tools
129
+ still available. Grok is not sandboxed. Add `--full` to explicitly invite
130
+ workspace investigation and edits.
126
131
  - `/bro btw <question>` asks immediately; `/bro btw` opens an empty thread.
127
132
  - `--fresh` starts a thread without seeding the main session's recent
128
133
  conversation text. Reopening without an access flag preserves the existing
129
- thread's access mode, including `--full`. Use `--sandbox` to return to sandbox
130
- mode; changing access mode starts a new thread. `--fresh` alone does not reset
134
+ thread's access mode, including `--full`. Use `--sandbox` to return to conversation-only
135
+ intent; changing access mode starts a new thread. `--fresh` alone does not reset
131
136
  the access mode.
132
137
  - The first turn is seeded with up to the last 8 turns of user/assistant
133
138
  conversation text (40,000 characters max, with a truncation notice); the
@@ -140,7 +145,7 @@ conversation unless you explicitly insert it into the editor.
140
145
  - `/insert-all`: inserts the full thread into the main editor without submitting (use `/insert-all!` to replace an existing editor draft)
141
146
  - `/retry`: re-asks the last question (empty Enter does the same)
142
147
  - `/clear`: resets the thread
143
- Any other text or slash-prefixed input (such as `/send` or `/copy!`) is not a composer command and is submitted directly as a question to the side conversation. Esc closes the modal. A visible `full · edits repo` badge shows whenever `--full` mode is active.
148
+ Any other text or slash-prefixed input (such as `/send` or `/copy!`) is not a composer command and is submitted directly as a question to the side conversation. Esc closes the modal. The header shows the model and reasoning effort the latest turn used (`default` when the model's own effort applies), and a visible `full · edits repo` badge shows whenever `--full` mode is active.
144
149
  - The thread lives in memory only — it clears when you switch Pi sessions,
145
150
  reload extensions, or quit Pi.
146
151
 
@@ -225,7 +230,7 @@ changes the form: it draws what you and the assistant said in the last few
225
230
  session turns as a shape instead of paragraphs. Capture keeps only user and
226
231
  assistant conversation text, including every intermediate assistant message in
227
232
  a turn — tool calls, tool results, reasoning, and images never leave the
228
- session. It runs the same isolated, sandboxed model call and shows the result
233
+ session. It runs the same backend-specific model call and shows the result
229
234
  in the same modal, never touching your conversation. `/bro show` uses its own
230
235
  draw prompt; the explanation modes and `bro-prompt.md` do not affect it.
231
236
 
@@ -729,11 +734,36 @@ configuration, disabled skills and no session persistence. This is a tool/config
729
734
  restriction, not an OS sandbox; built-in and managed Claude behavior can remain.
730
735
  Advisor uses safe mode and a fresh workspace process with permissions bypassed;
731
736
  it can modify files, and instructions to only advise remain behavioral. Running
732
- that mode as root may be rejected by Claude. BTW remains Agy-only: if a Claude
733
- shared default makes BTW unsupported, select an explicit Agy override.
737
+ that mode as root may be rejected by Claude. Claude BTW continuation is not wired yet (tracked in #58): if a Claude
738
+ shared default makes BTW unsupported, select an explicit Agy or Grok override.
734
739
 
735
740
  Doctor distinguishes CLI installation and configured authentication from a live
736
- request; it does not run a Claude model turn. `/bro usage` remains Agy-specific.
741
+ request; it does not run a Claude model turn.
742
+
743
+ ### Grok Build
744
+
745
+ Grok supports **explain, show, BTW (both modes), and advisor**, using the
746
+ separately authenticated `grok` CLI (tested with 1.0.41). Seeded choices are
747
+ `grok-4.7` and `grok-4.7-build-fast`; custom IDs are accepted. Efforts are
748
+ `default` (omit the flag), `low`, `medium`, `high`, and `xhigh`; model-specific
749
+ rejection is surfaced without silently changing your selection.
750
+
751
+ Grok runs with `--sandbox off --permission-mode bypassPermissions`. For
752
+ explain/show and ordinary BTW, Bro asks it to answer from supplied context
753
+ without investigating or modifying the workspace. **That is a prompt instruction,
754
+ not an enforced access restriction.** Tools, hooks, skills, plugins and MCP may
755
+ remain available. Explain/show start in a temporary directory; BTW uses your
756
+ workspace consistently because native resumed sessions retain their original cwd.
757
+ `--full` invites workspace access rather than imposing conversation-only intent.
758
+ Advisor runs fresh with workspace access. Bro does not blacklist tools merely
759
+ because they are more powerful than the immediate task requires.
760
+
761
+ BTW resumes using Grok's native session ID. Changing backend or access mode
762
+ starts a fresh Bro thread; IDs are never passed between backends. Bro removes
763
+ its private temporary prompt file, but Grok may retain sessions/logs under its
764
+ own settings. Bro does not copy credentials or change Grok configuration.
765
+ Cancellation targets the managed process group, not independently detached shell
766
+ work or external services. Doctor checks version, not authenticated connectivity.
737
767
 
738
768
  Use `/bro model`, `/bro effort`, and `/bro mode` to update the shared default
739
769
  and mode from Pi, `/bro config` to review or change the shared default and any
@@ -818,26 +848,24 @@ run `/bro doctor` for the exact problem.
818
848
  - **Side conversation requests**: `/bro btw` sends your side questions and, on
819
849
  the first turn, the seeded main-session conversation text to Agy. In `--full`
820
850
  mode the side agent additionally reads the workspace.
821
- - **Usage checks**: `/bro usage` checks your authenticated Agy limits without
822
- sending an assistant response or running a model turn.
823
851
  - **Setup checks**: `/bro doctor` checks Agy account and model availability
824
852
  without sending an assistant response or running a model turn.
825
853
  - **Context isolation**: Bro does not add explanations to Pi's conversation
826
854
  history, session files, or main-agent context.
827
- - **Side conversation (`/bro btw`)**: sandboxed by default — the side agent has
828
- no project access and runs in a temporary folder. With `--full` it runs in
829
- your workspace with auto-approved tools, so it can read and edit files while
830
- the main agent is also working; use `--full` only when you want that. The
831
- side thread is memory-only and clears when you switch sessions, reload
832
- extensions, or quit Pi.
855
+ - **Side conversation (`/bro btw`)**: Agy uses sandbox controls by default;
856
+ Grok uses conversation-only prompt instructions with normal workspace authority.
857
+ `--full` explicitly invites workspace access. Bro's thread state clears when
858
+ you switch sessions, reload extensions, or quit Pi; backend-native sessions
859
+ can persist independently. Prompt instructions are not access enforcement.
833
860
  - **Memory cache**: The latest explanation is stored only in process memory for
834
861
  `/bro open`. It clears when you switch Pi sessions, reload extensions, or quit
835
862
  Pi.
836
863
  - **File safety**: `/bro file` reads only regular files whose resolved path is
837
- inside Pi's current workspace, including after resolving symlinks. Bro does
838
- not modify them. It runs Agy in sandbox mode inside a temporary empty folder.
839
- This reduces project access, but it is not a security boundary. Bro only
840
- writes its own user settings file described above.
864
+ inside Pi's current workspace, including after resolving symlinks. Bro's extractor does
865
+ not modify them. The selected backend then receives extracted text: Agy uses
866
+ sandbox controls, Claude tool/config restrictions, and Grok prompt instructions
867
+ in a temporary directory. Grok retains normal tool authority; a request not
868
+ to modify files is behavioral, not a technical guarantee.
841
869
  - **Web requests**: `/bro url` connects directly to the target website. The site
842
870
  sees your IP address and Bro's user agent. Bro sends no browser cookies,
843
871
  authorization, or referrer information, and it refuses local, private, and
@@ -864,7 +892,7 @@ run `/bro doctor` for the exact problem.
864
892
  omitted with explicit markers (`[reasoning omitted]`, `[image omitted]`).
865
893
  - **Advisor tool execution & safety boundary**: The advisor process runs
866
894
  directly in your workspace (`cwd`) with auto-approved permissions
867
- (`--dangerously-skip-permissions`). It has real tool access (file reading,
895
+ (Agy/Claude permission bypass; Grok `--sandbox off --permission-mode bypassPermissions`). It has real tool access (file reading,
868
896
  search, command execution). The directive to only advise and leave edits to
869
897
  the executor is a **behavioral prompt instruction**, not an enforced sandbox
870
898
  or security boundary. Treat its findings as advice to verify before applying.
@@ -891,7 +919,7 @@ tool before giving it to Bro.
891
919
  - Direct webpage fetching does not currently use `HTTP_PROXY`, `HTTPS_PROXY`,
892
920
  or other proxy environment variables.
893
921
  - `/bro btw` threads are memory-only and do not survive reloads or restarts.
894
- The side conversation needs Agy's `--conversation` resume support; sandbox
922
+ The side conversation uses Agy `--conversation` or Grok `--resume`; conversation-only
895
923
  mode caps a turn at 2 minutes and full mode at 10 minutes.
896
924
  - `bro_advisor` requires Agy CLI `>=1.1.15` (for `--input-format stream-json`).
897
925
  Consultations run directly in the workspace with auto-approved permissions
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 (explain/show/advisor; no btw yet): it owns CLI selection, process invocation, progress/outcome normalization, continuation, and
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
 
@@ -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
- : "Claude Code could not start. Make sure `claude` 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`.";
73
77
  }
74
78
  return `${cli} could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
75
79
  }
@@ -489,9 +493,11 @@ async function executeAdvisorStdin(
489
493
  }
490
494
 
491
495
 
492
- // Claude Code supports every feature except btw (it has no continuation wiring yet).
493
- export function backendSupports(backend: "agy" | "claude", feature: BackendFeature): boolean {
494
- return backend === "agy" || feature !== "btw";
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";
495
501
  }
496
502
 
497
503
  type ClaudeEvent = {
@@ -689,6 +695,255 @@ async function executeClaude(
689
695
  }
690
696
  }
691
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
+
692
947
  // Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
693
948
  // e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
694
949
  // host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
@@ -707,7 +962,22 @@ export async function execute(
707
962
  ((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
708
963
  (request.feature !== "btw" && request.continuation)
709
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
+ }
710
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
+ }
711
981
  if (selection.backend === "claude") {
712
982
  if (!backendSupports("claude", request.feature)) {
713
983
  return { status: "failure", message: "Claude does not support /bro btw yet; switch btw back to Agy in `/bro config`." };