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.
- package/CHANGELOG.md +17 -0
- package/README.md +55 -27
- package/backend.ts +277 -7
- package/bro.ts +342 -115
- 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
|
|
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
|
|
121
|
-
|
|
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
|
-
- **
|
|
125
|
-
|
|
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
|
|
130
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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`)**:
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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.
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
(`--
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
494
|
-
|
|
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`." };
|