pi-bro 0.21.0 → 0.22.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 CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.22.0] - 2026-10-11
6
+
7
+ ### Fixed
8
+
9
+ - Persist settings with atomic replacement and mode 0o600 (#84):
10
+ - Replace direct `writeFile` with atomic sibling write (`.tmp`) and `rename` to prevent settings corruption on failure, crash, or disk full (`ENOSPC`).
11
+ - Flush writes before replacement (`flush: true`) and clean up temporary files on failure.
12
+ - Enforce `mode: 0o600` on replaced settings files so permissions are never loosened.
13
+ - Resolve symlinks via lstat and realpath before replacement to preserve dotfile-managed configuration symlinks, and reject if an existing symlink target cannot be resolved.
14
+ - Align on-disk state with modal rollback behavior in `/bro config`.
15
+
16
+ ### Added
17
+
18
+ - Guided Review (#104): open a PR by URL/number in local Pi to automatically get an explanation and quality assessment, captured Git evidence, findings with fix/check suggestions, diffs and optional private questions. Acquisition uses authenticated `gh` outside the active working tree. Findings remain model proposals, not approval or applied changes.
19
+ - Automatic local remembering of questions/drafts/reading position; reopening the same PR restores the captured revision without a model call. Bare `/bro guided-review` lists saved reviews; `resume` remains an alias. Close saves automatically; Stop and close cancels first. Copy finding copies to the system clipboard without submission.
20
+ - Confirmed same-revision regeneration replaces one current guide and preserves original private context; failed/cancelled generation retains it and save errors offer recovery. There is no guide-version UI or prototype-format migration. Review has its own backend override.
21
+ - Bounded live checks demonstrated an unchanged-caller defect and successful regeneration; manual UX accepted. This is not a general accuracy guarantee, live five-backend matrix or complete #104 scope. Notes, examination tracking, branch/update review and feedback delivery are not implemented.
22
+
23
+ - Durable advisor steering across sessions in `~/.pi/agent/bro-advisor.md` (#97):
24
+ - Ingest standing priorities from `bro-advisor.md` alongside session steering (`/bro advisor-steer`).
25
+ - Labeled prompt composition in `buildAdvisorPrompt`: standing priorities appear under `### Standing priorities (durable across sessions)` and session priorities under `### Session priorities (this session only)`.
26
+ - Session-specific priorities take precedence over standing defaults where they conflict.
27
+ - Fail-closed validation rejects files > 64 KB and > 4,000 characters before spawning any backend process.
28
+ - Doctor reports both `Advisor steering (session)` and `Advisor steering (durable)`.
29
+ - Tool result context line and details track `steeringSources: { durable, session }` while preserving backwards-compatible `steeringIncluded` boolean.
30
+ - Retries use a frozen prompt snapshot built prior to `runAdvisorWithRetries`.
31
+ - Clearing session steering leaves durable steering intact.
32
+
33
+ ### Changed
34
+
35
+ - Doctor treats an unsupported optional review backend as an informational override hint, not a broken installation; healthy Agy/Grok/Codex defaults still report ready.
36
+ - Review is file-only on Claude/Muse: no shell, write or web tools; Agy/Grok/Codex review calls fail closed without switching backend. Other features retain their access behavior.
37
+ - Capture uses depth-1 head/merge-base trees instead of full history; fetch gets a ten-minute bound while evidence stays offline. Interrupted-capture recovery remains.
38
+ - Removed prototype record migrations after one-time backup/normalization of development records; split validation and formatted review modules/tests to the repository tab style.
39
+
40
+ - Guided Review UX consistency pass: origin-preserving Back, focused-action Enter, topic-filtered findings, pane-aware paging, visible focus/overflow, selected-item previews and compact full-width 36×18 layout. Discussion follows conversation/composer/actions order; save errors have shared retry controls and failed questions have draft-safe explicit edit/resend. Request errors are separate from model answers, streaming follows again at the bottom, and acquisition/resume/close messages give clearer orientation. Current review behavior, controls, access/storage and recovery are documented in the user guide; developer/testing guidance records its implementation rules and verification limits. Review itself has no publication action.
41
+ - Remove dead declarations and stale backend naming (#90):
42
+ - Enable native TypeScript unused checks (`noUnusedLocals`, `noUnusedParameters`) in `tsconfig.json`.
43
+ - Remove verified unused declarations across `bro.ts`, `backend.test.ts`, `benchmark/run.ts`, `codex.test.ts`, and `release.test.ts`.
44
+ - Drop pass-through re-exports from `bro.ts` (`agyFailureMessage`, `agySelection`, `advisorFlagErrorHint`, `parseBtwAgyLine`); tests and consumers import them directly from `backend.ts`.
45
+ - Rename multi-backend facilities with obsolete Agy prefixes: `runAgyText` -> `runBackendText` in `bro.ts` and `killAgyGroup` -> `killProcessGroup` in `backend.ts`.
46
+ - Modernize outdated single-backend and pre-extraction comments.
47
+ - Separate Bro responsibilities and unify settings and backend-selection policy (#83):
48
+ - Extract `settings.ts` (schema, typed `EXTERNAL_BACKENDS` metadata, selection policy, pure transitions, and file persistence).
49
+ - Extract `sources.ts` (self-contained document text extraction and public web scraping with SSRF protection).
50
+ - Extract `config-ui.ts` (`/bro config` modal, setting items, and async save serialization).
51
+ - Extract `util.ts` (shared leaf utility helpers).
52
+ - Unify legacy flat and v2 settings parsing into one converged validation pipeline in `settings.ts`, eliminating duplicate validation branches.
53
+ - Initialize clean version 2 settings on fresh installations in `ensureSettingsFile()`.
54
+ - Share pure state transitions (`applyModelChange`, `applyEffortChange`) across default and capability override rows.
55
+ - Fix Agy variant model reselection bug where resolved effort from suffixed variants was lost.
56
+ - Consolidate CLI version probes in `/bro doctor` into a single `checkCliVersion` helper.
57
+
5
58
  ## [0.21.0] - 2026-10-04
6
59
 
7
60
  ### Added
package/README.md CHANGED
@@ -1,14 +1,11 @@
1
1
  # pi-bro
2
2
 
3
- Turn a dense AI reply, pasted text, local document, or public webpage into a
4
- plain-language explanation — or open a separate side conversation with
5
- `/bro btw` — without adding anything to your main agent's context.
3
+ Turn a dense AI reply, pasted text, document or webpage into a plain-language explanation. Ask side questions with `/bro btw`, or understand and assess a PR with `/bro guided-review`. These windows do not add their discussions to your main agent's context.
6
4
 
7
5
  `pi-bro` is an extension for [Earendil Pi](https://github.com/earendil-works/pi).
8
6
  Experimental [PiG 0.3.0 compatibility](docs/pig-compatibility.md) uses the same npm package and requires Node.js. See the compatibility notes for verified coverage and the PiG RPC tool-exclusion limitation.
9
7
  It opens explanations in a separate modal and runs them through a CLI backend
10
- you already have installed and signed in to. Explain, show, BTW, and the
11
- advisor all work across five backends:
8
+ you already have installed and signed in to. Explain, show, BTW and advisor use five backend adapters. Guided Review generation/questions require Claude or Muse file-only inspection (live verification varies by feature):
12
9
 
13
10
  - [Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`) — the default for new settings
14
11
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
@@ -75,7 +72,9 @@ text directly captures a new source the same way.
75
72
  | `/bro doctor` | Check Bro's settings, preferences, and each selected backend, with the effective backend/model/effort per feature. |
76
73
  | `/bro mode [brief\|balanced\|faithful]` | View or choose the explanation mode. |
77
74
  | `/bro preferences` | View or edit what Bro knows about you and how you like answers; see [Preferences](#preferences). |
78
- | `/bro config` | Open an interactive settings screen for the shared default backend/model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor) overrides. |
75
+ | `/bro config` | Open settings for the shared backend/model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor/review) overrides. |
76
+ | `/bro guided-review <PR number or URL>` | Automatically explain and assess a captured PR in local Pi, with source evidence and optional private questions. Opening the same PR restores its saved review. |
77
+ | `/bro guided-review` | List saved reviews; `resume` is an alias. |
79
78
  | `/bro btw [question]` | Open a side conversation in a modal, seeded with recent main-session context. Starts conversation-only; type `/mode` inside to toggle full permission (read and edit the workspace) without losing the thread. |
80
79
  | `/bro advisor` | Quick notice of whether the executor's `bro_advisor` tool is available right now, pointing at `/bro config`, `/bro advisor-steer`, and `/bro doctor`. |
81
80
  | `/bro advisor-steer` | View, edit, save, or clear the one persistent steering brief the advisor always sees. |
@@ -128,6 +127,44 @@ selection may be unavailable or visually extend outside the modal depending on
128
127
  your terminal mode; press **C** to copy the complete explanation reliably.
129
128
 
130
129
 
130
+ ## Guided Review
131
+
132
+ **Open a PR → read and investigate → leave when satisfied.** Bro automatically
133
+ prepares an explanation and quality assessment with captured code, ranked
134
+ findings and suggested fixes/checks. Asking private questions is optional.
135
+ Nothing is automatically applied or posted to GitHub.
136
+
137
+ ```text
138
+ /bro guided-review https://github.com/OWNER/REPO/pull/123
139
+ ```
140
+
141
+ Local Pi interactive mode, Git, authenticated `gh`, and Claude or Muse configured for review
142
+ are required. A new review makes a model call using that backend's account.
143
+ Opening the same PR restores its saved review without another model call.
144
+ Bare `/bro guided-review` lists saved reviews (`resume` is an alias).
145
+
146
+ Contents holds Overview, topics, Findings, Coverage, Changed files and Your
147
+ questions. Enter opens an item or focuses contextual actions; Enter on a
148
+ highlighted action activates it. Tab changes focus. Esc goes back, then closes
149
+ from Contents. Your work is saved automatically; **Close** leaves normally,
150
+ and **Stop and close** cancels active work before saving and leaving.
151
+
152
+ Optional conveniences:
153
+ - **Copy finding** copies its text to the system clipboard; nothing is submitted.
154
+ - **Regenerate guide** makes a confirmed model call about the **same captured
155
+ revision**. Success replaces the guide while retaining original private
156
+ discussion context; failure keeps it. It does not fetch later commits.
157
+
158
+ Reopening also keeps the captured revision, even after the author pushes.
159
+ Findings are model suggestions, not approval; citations show real locations,
160
+ not proof of the conclusion. Review uses Claude or Muse file-only inspection: shell, writes and web tools are disabled. Agy/Grok/Codex review requests fail clearly; choose a review override in `/bro config`. Saved reviews remain readable with any selection. Repository text can still mislead the model; tool restrictions are not proof of its conclusions.
161
+ Use `pi --tui-mode fullscreen` for pane-local wheel scrolling.
162
+
163
+ See the [Guided Review guide](docs/guided-review.md) for controls, calls and
164
+ costs, source freshness, access/privacy, local storage, recovery and current
165
+ scope. Notes, examination tracking, branch/update review and feedback delivery
166
+ are not implemented.
167
+
131
168
  ## Bro btw (side conversation)
132
169
 
133
170
  `/bro btw` opens a separate multi-turn conversation in a modal, so you can ask
@@ -201,18 +238,21 @@ presence, and backend compatibility (for Agy, a minimum CLI version with an
201
238
  return advice, leaving edits to the executor, but that boundary is a
202
239
  behavioral prompt instruction rather than an enforced sandbox constraint, so
203
240
  treat its findings as advice to verify, not a guaranteed hands-off review.
204
- - **Steering**: `/bro advisor-steer` opens an editor for one persistent
205
- steering brief — e.g. "quick prototype; keep A and B careful, everything
206
- else minimal" — that the advisor always reads. **Ctrl+S** saves and keeps
207
- the editor open; **Enter** or **Shift+Enter** inserts a newline; **Ctrl+K**
208
- clears both the saved brief and draft while staying open; **Ctrl+C** copies
209
- the entire current draft, including unsaved edits; and **Esc** closes without
210
- saving unsaved edits. The brief is **never added to Pi's conversation or
211
- sent to the main model** — the advisor is the only thing that reads it.
212
- - **Persistence**: the steering brief persists with the Pi session (not
213
- globally, not per project) as custom extension data in the session file and
214
- is restored on resume or reload. Forking a session inherits it; edits made
215
- after the fork are independent of the original branch.
241
+ - **Steering**: The advisor reads standing priorities from `~/.pi/agent/bro-advisor.md`
242
+ and session-specific priorities from `/bro advisor-steer`.
243
+ `/bro advisor-steer` opens an editor for one session steering brief — e.g. "quick
244
+ prototype; keep A and B careful, everything else minimal". Session steering takes
245
+ precedence over durable standing defaults where they conflict.
246
+ **Ctrl+S** saves and keeps the editor open; **Enter** or **Shift+Enter** inserts
247
+ a newline; **Ctrl+K** clears both the saved session brief and draft while staying open;
248
+ **Ctrl+C** copies the entire current draft, including unsaved edits; and **Esc**
249
+ closes without saving unsaved edits. Neither steering source is **ever added to Pi's
250
+ conversation or sent to the main model** — the advisor is the only thing that reads them.
251
+ - **Persistence**: standing defaults persist across all sessions in `~/.pi/agent/bro-advisor.md`.
252
+ The session steering brief persists with the Pi session as custom extension data in the
253
+ session file (`bro-advisor-steering`) and is restored on resume or reload. Forking a
254
+ session inherits session steering; edits made after the fork are independent of the
255
+ original branch. Clearing session steering leaves durable steering intact.
216
256
  - **Retries**: on an invocation failure (not a completed answer — "I need
217
257
  more evidence" is a normal result, not a failure), Bro retries with the
218
258
  identical snapshot, steering, and question: once after 5 seconds, once more
@@ -717,7 +757,7 @@ succeed.
717
757
  ## Settings
718
758
 
719
759
  Use `/bro config` to review or change the shared default backend/model/effort
720
- and any per-capability (explain/show/btw/advisor) overrides, the explanation
760
+ and any per-capability (explain/show/btw/advisor/review) overrides, the explanation
721
761
  mode, and the default show turn count. Changes save immediately. Esc inside a
722
762
  picker cancels that pick; Esc on the settings screen closes it, keeping
723
763
  whatever was already saved. A failed save (for example, a read-only file) is
@@ -770,13 +810,13 @@ credentials, and does not copy credentials or rewrite backend configuration.
770
810
  Each backend and your model provider may retain sessions, logs, and request
771
811
  data under their own settings and policies.
772
812
 
773
- | | Explain / show | BTW conversation-only | BTW full permission | Advisor |
774
- | --- | --- | --- | --- | --- |
775
- | **Agy** | Temporary directory, Agy sandbox | Temporary directory, Agy sandbox | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
776
- | **Claude Code** | Scratch directory, tools/MCP/skills disabled, no session persistence | Workspace, tools disabled | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
777
- | **Grok** | Temporary directory, **prompt instruction only** | Workspace, **prompt instruction only** | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
778
- | **Codex** | Scratch directory, read-only sandbox, ephemeral session | Workspace, read-only sandbox | Workspace, approvals and sandbox bypassed | Fresh workspace process, approvals and sandbox bypassed |
779
- | **Muse** | Scratch directory, approval/write/shell disabled, no session log | Workspace, approval/write/shell disabled | Workspace, permissions bypassed (`--yolo`) | Fresh workspace process, permissions bypassed (`--yolo`) |
813
+ | | Explain / show | BTW conversation-only | BTW full permission | Advisor | Guided Review |
814
+ | --- | --- | --- | --- | --- | --- |
815
+ | **Agy** | Temporary directory, Agy sandbox | Temporary directory, Agy sandbox | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed | Unavailable; choose Claude/Muse |
816
+ | **Claude Code** | Scratch directory, tools/MCP/skills disabled, no session persistence | Workspace, tools disabled | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed | Restricted Read/Grep/Glob only; confined checkout, no persistence |
817
+ | **Grok** | Temporary directory, **prompt instruction only** | Workspace, **prompt instruction only** | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed | Unavailable; choose Claude/Muse |
818
+ | **Codex** | Scratch directory, read-only sandbox, ephemeral session | Workspace, read-only sandbox | Workspace, approvals and sandbox bypassed | Fresh workspace process, approvals and sandbox bypassed | Unavailable; command execution is not allowed for review |
819
+ | **Muse** | Scratch directory, approval/write/shell disabled, no session log | Workspace, approval/write/shell disabled | Workspace, permissions bypassed (`--yolo`) | Fresh workspace process, permissions bypassed (`--yolo`) | Captured checkout; writes/shell/web disabled, no session log |
780
820
 
781
821
  - Grok always runs with its sandbox off and permissions bypassed; its tools,
782
822
  hooks, skills, plugins, and MCP may remain available. "Answer only from the
@@ -805,7 +845,7 @@ data under their own settings and policies.
805
845
  ### Configuration precedence
806
846
 
807
847
  When resolving backend, model, and reasoning effort:
808
- 1. **Per-capability override**: `overrides.<capability>` (`explain`, `show`, `btw`, or `advisor`) pins that capability's complete selection.
848
+ 1. **Per-capability override**: `overrides.<capability>` (`explain`, `show`, `btw`, `advisor`, or `review`) pins that capability's complete selection.
809
849
  2. **Shared default**: otherwise the capability inherits `default` (root `model`/`effort` in older flat files).
810
850
  3. **Agy catalog normalization**: for Agy selections, Bro maps suffixed variant IDs and handles fixed-effort models.
811
851
  4. **Initial file creation only**: `PI_BRO_MODEL` has no effect once the settings file exists.
@@ -826,7 +866,7 @@ When shaping an answer, each part has one owner:
826
866
  ## Preferences
827
867
 
828
868
  Tell Bro about yourself and how you like answers. Bro adds what you write to
829
- every explain, `/bro show`, and `/bro btw` prompt as a labelled section,
869
+ every explain, `/bro show`, `/bro btw`, and Guided Review prompt as a labelled section,
830
870
  alongside its own instructions. It never replaces them: modes, **M**, and the
831
871
  source rules keep working. The advisor never receives your preferences; use
832
872
  `/bro advisor-steer` for it.
@@ -860,11 +900,11 @@ that is set):
860
900
  - **What preferences can change**: wording, tone, technical depth, length in
861
901
  BTW, and the answer language. If you name a language, code, commands, paths,
862
902
  names, and numbers still stay exactly as written. In `/bro show`,
863
- preferences only change wording and language. A per-run choice (the mode,
864
- **M**, a Show steering query, or what a BTW question asks for) wins over a
903
+ preferences only change wording and language. Guided Review also receives preferences for explanation/answer wording; they do not override its evidence or review rules. A per-run choice (the mode,
904
+ **M**, a Show steering query, or what a BTW/review question asks for) wins over a
865
905
  standing preference.
866
906
  - **Limit**: 4,000 characters, because the text goes with every request. A
867
- longer file stops explain, Show, and BTW with an error until you trim it;
907
+ longer file stops explain, Show, BTW, and Guided Review with an error until you trim it;
868
908
  Bro never cuts it silently. `/bro doctor` reports the problem, and the
869
909
  editor still opens the file so you can fix it.
870
910
  - **BTW threads**: when your preferences change, the next side question starts
@@ -886,8 +926,9 @@ read. Move what you want to keep into `/bro preferences`, and choose
886
926
  seeded main-session conversation text (plus earlier turns when a native
887
927
  session is reseeded) to the selected backend. In full permission mode the
888
928
  side agent can additionally read and edit the workspace.
929
+ - **Guided Review requests**: PR identity/description, captured diff, relevant private discussion and preferences go to the selected backend, which can inspect captured files using Claude/Muse read-only tools. Main-session context is not attached. Review shell/write/web tools are disabled; Claude file tools are confined with `--restricted`, Muse uses workspace-scoped inspection. Prompt injection can still corrupt conclusions or reveal captured content to the provider; this is not a general confidentiality guarantee. See [Guided Review access and storage](docs/guided-review.md#access-privacy-and-storage).
889
930
  - **Preferences**: `bro-preferences.md` is sent with every explain, Show,
890
- and BTW request to the selected backend. It is never sent to the advisor or
931
+ BTW, and Guided Review request to the selected backend. It is never sent to the advisor or
891
932
  to Pi's main model.
892
933
  - **Advisor requests**: `bro_advisor` sends the executor agent's system
893
934
  instructions, active tool list (excluding `bro_advisor`), ordered
@@ -904,10 +945,12 @@ read. Move what you want to keep into `/bro preferences`, and choose
904
945
  steering brief to Pi's conversation history or main-agent context. BTW text
905
946
  reaches the main editor only through `/insert` or `/insert-all`, and advisor
906
947
  results appear as normal tool results in the executor's transcript.
948
+ - **Saved reviews**: Guided Review records, questions, drafts and captured Git source persist under the host's `getAgentDir()/bro-reviews/`. No automatic cleanup or sync is provided; see [storage and removal](docs/guided-review.md#access-privacy-and-storage).
907
949
  - **Memory**: the latest explanation (for `/bro open`) and the BTW thread live
908
950
  only in process memory and clear when you switch Pi sessions, reload
909
951
  extensions, or quit Pi. Backend-native sessions can persist independently.
910
- The advisor steering brief is stored as session-scoped extension data
952
+ Standing advisor priorities are stored in `~/.pi/agent/bro-advisor.md`, and
953
+ the session steering brief is stored as session-scoped extension data
911
954
  (`bro-advisor-steering`) in the session file.
912
955
  - **File safety**: `/bro file` reads only regular files whose resolved path is
913
956
  inside Pi's current workspace, including after resolving symlinks. Bro's
@@ -925,7 +968,7 @@ read. Move what you want to keep into `/bro preferences`, and choose
925
968
  Bro writes it to `/tmp/pi-bro-<uid>/bro-show-<hash>.html` with a restrictive
926
969
  Content-Security-Policy, and opens it in your browser only when you press
927
970
  **O**. **C** copies the full reply, including the HTML.
928
- - **Clipboard**: **C**, `/copy`, and `/copy-all` copy text to your system
971
+ - **Clipboard**: **C**, `/copy`, `/copy-all`, and Guided Review's Copy finding copy text to your system
929
972
  clipboard, where your operating system or clipboard manager may retain it.
930
973
 
931
974
  ## Troubleshooting and current limits
@@ -953,8 +996,8 @@ tool before giving it to Bro.
953
996
  - HTML diagrams open in your default browser; pressing **O** on a remote or
954
997
  headless session with no display reports the failure instead of opening
955
998
  anything.
956
- - Keeps only the latest explanation in memory and does not store history or
957
- export directly to files.
999
+ - Explain keeps only the latest result in memory and does not export directly to files. Guided Review separately persists review records and captured source.
1000
+ - Guided Review is local-Pi interactive only, keeps its captured revision on reopen, and does not publish, review a branch or fetch later PR changes. See [review recovery and limits](docs/guided-review.md#if-something-goes-wrong).
958
1001
  - Bro temporarily captures mouse input while its modal is open so mouse-wheel
959
1002
  and trackpad scrolling work in regular and fullscreen modes. Native mouse
960
1003
  selection may be unavailable or visually extend outside the Bro window;
@@ -968,7 +1011,7 @@ npm test
968
1011
  pi --tui-mode fullscreen -e ./bro.ts
969
1012
  ```
970
1013
 
971
- `npm test` uses fake `agy`, `claude`, and `grok` executables and never calls an
1014
+ `npm test` uses fake `agy`, `claude`, `grok`, `codex`, and `muse` executables and never calls an
972
1015
  external model. See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the code
973
1016
  map and invariants, [docs/TESTING.md](docs/TESTING.md) for the manual
974
1017
  end-to-end checklist, and [docs/README.md](docs/README.md) for the docs index.
package/backend.ts CHANGED
@@ -3,14 +3,17 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- // Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
7
- // This implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
8
- // Claude Code CLI (all features) and the Grok CLI (all features): it owns CLI selection, process invocation, progress/outcome normalization, continuation, and
9
- // single-attempt cleanup. Feature code (bro.ts) keeps retries, UI, source/session capture and
10
- // settings.
6
+ // Shared internal execution boundary for explain, show, btw, advisor, and review.
7
+ // This implements docs/plans/2026-09-22-shared-backend-design.md across all supported backends
8
+ // (Agy, Claude Code, Grok, Codex, Muse): it owns CLI selection, process invocation,
9
+ // progress/outcome normalization, continuation, and single-attempt cleanup. Feature code
10
+ // keeps retries, UI, source/session capture and settings.
11
11
 
12
- export type BackendFeature = "explain" | "show" | "btw" | "advisor";
12
+ export type BackendFeature = "explain" | "show" | "btw" | "advisor" | "review";
13
13
  export type BackendAccess = "restricted" | "workspace-full";
14
+ export function reviewBackendError(backend: string): string | undefined {
15
+ if (backend !== "claude" && backend !== "muse") return "Guided Review requires Claude or Muse for file-only inspection. Set the review backend in /bro config; saved reviews remain readable.";
16
+ }
14
17
  export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
15
18
  export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
16
19
  export const GROK_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
@@ -90,8 +93,8 @@ function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null, cli = "Agy")
90
93
  }
91
94
 
92
95
  // Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
93
- // just the immediate agy process -- child.kill() alone only ever reaches the immediate child.
94
- function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
96
+ // just the immediate child process -- child.kill() alone only ever reaches the immediate child.
97
+ function killProcessGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
95
98
  if (process.platform !== "win32" && typeof child.pid === "number") {
96
99
  try {
97
100
  process.kill(-child.pid, signalName);
@@ -104,7 +107,7 @@ function killAgyGroup(child: ChildProcess, signalName: NodeJS.Signals): void {
104
107
  }
105
108
 
106
109
  // The three causes that stop an in-flight attempt: user cancellation, the host-imposed deadline,
107
- // and a protocol failure (malformed/inconsistent Agy output). Exactly one is latched -- the first
110
+ // and a protocol failure (malformed/inconsistent backend output). Exactly one is latched -- the first
108
111
  // to occur -- and it is never relabeled by a later signal (e.g. a cancel arriving after a deadline
109
112
  // already fired stays a timeout, not a cancellation).
110
113
  type StopCause = "cancelled" | "timeout" | "protocol";
@@ -139,9 +142,9 @@ export function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineM
139
142
  if (cause) return; // latched: the first stop cause wins
140
143
  cause = next;
141
144
  if (isClosed) return;
142
- killAgyGroup(child, "SIGTERM");
145
+ killProcessGroup(child, "SIGTERM");
143
146
  killTimer = setTimeout(() => {
144
- killAgyGroup(child, "SIGKILL");
147
+ killProcessGroup(child, "SIGKILL");
145
148
  // Detached descendants (or Windows grandchildren) may retain inherited pipes.
146
149
  // Stop waiting on those pipes after escalation; never promote this stop to success.
147
150
  child.stdin?.destroy();
@@ -229,12 +232,12 @@ async function executeArgvPrint(
229
232
  // stdin protocol rather than risking Linux's per-argument byte limit.
230
233
  const stdinPrompt = Buffer.byteLength(request.prompt, "utf8") >= 120_000;
231
234
  const isBtw = request.feature === "btw";
232
- const full = isBtw && request.access === "workspace-full";
233
- const deadlineMs = deadlineMsOverride ?? (isBtw ? (full ? 610_000 : 130_000) : 125_000);
235
+ const full = request.access === "workspace-full";
236
+ const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
234
237
  const printTimeout = full ? "10m" : "2m";
235
- const action = isBtw ? "answer the side question" : "simplify the response";
236
- const timeoutVerb = isBtw ? "during the side conversation" : "while simplifying the response";
237
- const emptyTextMessage = isBtw ? "Agy returned no answer for the side question." : "Agy returned no final explanation.";
238
+ const action = request.feature === "review" ? "complete the guided review request" : isBtw ? "answer the side question" : "simplify the response";
239
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isBtw ? "during the side conversation" : "while simplifying the response";
240
+ const emptyTextMessage = request.feature === "review" ? "Agy returned no response for Guided Review." : isBtw ? "Agy returned no answer for the side question." : "Agy returned no final explanation.";
238
241
 
239
242
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
240
243
 
@@ -257,7 +260,7 @@ async function executeArgvPrint(
257
260
  ...(selection.effort ? ["--effort", selection.effort] : []),
258
261
  "--print-timeout",
259
262
  printTimeout,
260
- ...(request.continuation ? ["--conversation", request.continuation.id] : []),
263
+ ...(isBtw && request.continuation ? ["--conversation", request.continuation.id] : []),
261
264
  ...(stdinPrompt ? ["--input-format", "stream-json"] : ["--print", request.prompt]),
262
265
  ],
263
266
  {
@@ -565,12 +568,13 @@ async function executeClaude(
565
568
  ): Promise<BackendOutcome> {
566
569
  const isAdvisor = request.feature === "advisor";
567
570
  const isBtw = request.feature === "btw";
571
+ const inspect = request.feature === "review";
568
572
  const full = request.access === "workspace-full";
569
- const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
570
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
573
+ const deadlineMs = deadlineMsOverride ?? (full || inspect ? 610_000 : isBtw ? 130_000 : 125_000);
574
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
571
575
 
572
576
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
573
- const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
577
+ const runDirectory = full || isBtw || inspect ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
574
578
  if (signal.aborted) {
575
579
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
576
580
  return { status: "cancelled", message: "Canceled." };
@@ -588,7 +592,9 @@ async function executeClaude(
588
592
  "stream-json",
589
593
  "--verbose",
590
594
  "--include-partial-messages",
591
- ...(full
595
+ ...(inspect
596
+ ? ["--restricted", "--tools", "Read,Grep,Glob", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]
597
+ : full
592
598
  ? ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--dangerously-skip-permissions"]
593
599
  : ["--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]),
594
600
  ...(isBtw && request.continuation ? ["--resume", request.continuation.id] : []),
@@ -685,7 +691,7 @@ async function executeClaude(
685
691
  const cause = attempt.causeOf();
686
692
  if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText };
687
693
  if (cause === "timeout") {
688
- const during = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
694
+ const during = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
689
695
  return { status: "timeout", message: `Claude timed out ${during}. Run \`/bro doctor\` for setup help.`, partialText };
690
696
  }
691
697
  if (protocolError) return { status: "failure", message: withDoctor(protocolError), partialText };
@@ -706,7 +712,7 @@ async function executeClaude(
706
712
  return { status: "failure", message: withDoctor(`Claude exited without a result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), partialText };
707
713
  }
708
714
  const text = final.trim();
709
- if (!text) return { status: "failure", message: withDoctor(isBtw ? "Claude returned no answer for the side question." : "Claude returned no final answer."), partialText };
715
+ if (!text) return { status: "failure", message: withDoctor(request.feature === "review" ? "Claude returned no response for Guided Review." : isBtw ? "Claude returned no answer for the side question." : "Claude returned no final answer."), partialText };
710
716
  if (!isBtw) return { status: "success", text };
711
717
  // btw continuation: the session id must be reported, consistent, and (on resume) unchanged.
712
718
  const sessionId = resultSessionId ?? initSessionId;
@@ -767,10 +773,10 @@ async function executeGrok(
767
773
  ): Promise<BackendOutcome> {
768
774
  const isAdvisor = request.feature === "advisor";
769
775
  const isBtw = request.feature === "btw";
770
- const deadlineMs = deadlineMsOverride ?? (isAdvisor || (isBtw && request.access === "workspace-full") ? 610_000 : isBtw ? 130_000 : 125_000);
771
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
772
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
773
- const emptyTextMessage = isAdvisor ? "Grok returned no advice." : isBtw ? "Grok returned no answer for the side question." : "Grok returned no final explanation.";
776
+ const deadlineMs = deadlineMsOverride ?? (request.access === "workspace-full" ? 610_000 : isBtw ? 130_000 : 125_000);
777
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
778
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
779
+ const emptyTextMessage = request.feature === "review" ? "Grok returned no response for Guided Review." : isAdvisor ? "Grok returned no advice." : isBtw ? "Grok returned no answer for the side question." : "Grok returned no final explanation.";
774
780
  const prompt = request.access === "restricted" ? `${GROK_RESTRICTED_PREFIX}\n\n${request.prompt}` : request.prompt;
775
781
 
776
782
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
@@ -780,7 +786,7 @@ async function executeGrok(
780
786
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
781
787
  const promptFile = join(promptDirectory, "prompt.txt");
782
788
  await writeFile(promptFile, prompt, { encoding: "utf8", mode: 0o600 });
783
- if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
789
+ if (request.access !== "workspace-full" && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
784
790
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
785
791
 
786
792
  const child = spawn(
@@ -1003,12 +1009,12 @@ async function executeCodex(
1003
1009
  const isBtw = request.feature === "btw";
1004
1010
  const full = request.access === "workspace-full";
1005
1011
  const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1006
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1007
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1008
- const emptyTextMessage = isAdvisor ? "Codex returned no advice." : isBtw ? "Codex returned no answer for the side question." : "Codex returned no final explanation.";
1012
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1013
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1014
+ const emptyTextMessage = request.feature === "review" ? "Codex returned no response for Guided Review." : isAdvisor ? "Codex returned no advice." : isBtw ? "Codex returned no answer for the side question." : "Codex returned no final explanation.";
1009
1015
 
1010
1016
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1011
- const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
1017
+ const runDirectory = full || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
1012
1018
  if (signal.aborted) {
1013
1019
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
1014
1020
  return { status: "cancelled", message: "Canceled." };
@@ -1172,11 +1178,12 @@ async function executeMuse(
1172
1178
  ): Promise<BackendOutcome> {
1173
1179
  const isAdvisor = request.feature === "advisor";
1174
1180
  const isBtw = request.feature === "btw";
1181
+ const inspect = request.feature === "review";
1175
1182
  const full = request.access === "workspace-full";
1176
- const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1177
- const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1178
- const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1179
- const emptyTextMessage = isAdvisor ? "Muse returned no advice." : isBtw ? "Muse returned no answer for the side question." : "Muse returned no final explanation.";
1183
+ const deadlineMs = deadlineMsOverride ?? (full || inspect ? 610_000 : isBtw ? 130_000 : 125_000);
1184
+ const action = request.feature === "review" ? "complete the guided review request" : isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1185
+ const timeoutVerb = request.feature === "review" ? "during the guided review request" : isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1186
+ const emptyTextMessage = request.feature === "review" ? "Muse returned no response for Guided Review." : isAdvisor ? "Muse returned no advice." : isBtw ? "Muse returned no answer for the side question." : "Muse returned no final explanation.";
1180
1187
 
1181
1188
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1182
1189
  const promptDirectory = await mkdtemp(join(tmpdir(), "pi-bro-muse-"));
@@ -1186,7 +1193,7 @@ async function executeMuse(
1186
1193
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1187
1194
  const promptFile = join(promptDirectory, "prompt.txt");
1188
1195
  await writeFile(promptFile, request.prompt, { encoding: "utf8", mode: 0o600 });
1189
- if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1196
+ if (!full && !isBtw && !inspect) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1190
1197
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1191
1198
  const workspace = runDirectory ?? request.cwd!;
1192
1199
 
@@ -1197,6 +1204,7 @@ async function executeMuse(
1197
1204
  "--workspace", workspace,
1198
1205
  ...(!isBtw ? ["--no-session-log"] : []),
1199
1206
  ...(full ? ["--yolo"] : ["--disable-approval", "--disable-write", "--disable-shell"]),
1207
+ ...(inspect ? ["--disable-web-tools", "--no-foreign-personal-context"] : []),
1200
1208
  ...(isBtw && request.continuation ? ["--session-id", request.continuation.id] : []),
1201
1209
  "--model", selection.model,
1202
1210
  ...(selection.effort ? ["--reasoning-effort", selection.effort] : []),
@@ -1341,7 +1349,7 @@ async function executeMuse(
1341
1349
  }
1342
1350
  }
1343
1351
 
1344
- // Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
1352
+ // Single-attempt executor shared by all features. Never retries (retries are feature-owned,
1345
1353
  // e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
1346
1354
  // host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
1347
1355
  // after a bounded grace period) before resolving. `options` is for offline tests only -- production
@@ -1356,10 +1364,15 @@ export async function execute(
1356
1364
  if (
1357
1365
  (request.access === "workspace-full" && !request.cwd?.trim()) ||
1358
1366
  (request.feature === "advisor" && request.access !== "workspace-full") ||
1367
+ (request.feature === "review" && (request.access !== "restricted" || !request.cwd?.trim())) ||
1359
1368
  ((request.feature === "explain" || request.feature === "show") && request.access !== "restricted") ||
1360
1369
  (request.feature !== "btw" && request.continuation)
1361
1370
  ) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1362
1371
  const backend = (selection as { backend?: unknown }).backend;
1372
+ if (request.feature === "review") {
1373
+ const error = reviewBackendError(String(backend ?? "agy"));
1374
+ if (error) return { status: "failure", message: error };
1375
+ }
1363
1376
  // An explicit tag guard: a stale or corrupt runtime tag must fail, never fall through to Agy.
1364
1377
  if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok" && backend !== "codex" && backend !== "muse") {
1365
1378
  return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude, Grok, Codex or Muse in \`/bro config\`.` };
@@ -1367,7 +1380,7 @@ export async function execute(
1367
1380
  const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
1368
1381
  if (selection.backend === "codex") {
1369
1382
  // Codex btw always runs (and resumes) in the caller's workspace, even restricted.
1370
- if (request.feature === "btw" && !request.cwd?.trim()) {
1383
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1371
1384
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1372
1385
  }
1373
1386
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !CODEX_EFFORTS.includes(selection.effort))) {
@@ -1377,7 +1390,7 @@ export async function execute(
1377
1390
  }
1378
1391
  if (selection.backend === "muse") {
1379
1392
  // Muse btw always runs (and resumes) in the caller's workspace, even restricted.
1380
- if (request.feature === "btw" && !request.cwd?.trim()) {
1393
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1381
1394
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1382
1395
  }
1383
1396
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !MUSE_EFFORTS.includes(selection.effort))) {
@@ -1387,7 +1400,7 @@ export async function execute(
1387
1400
  }
1388
1401
  if (selection.backend === "grok") {
1389
1402
  // Grok btw always runs (and resumes) in the caller's workspace, even restricted.
1390
- if (request.feature === "btw" && !request.cwd?.trim()) {
1403
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1391
1404
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1392
1405
  }
1393
1406
  if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !GROK_EFFORTS.includes(selection.effort))) {
@@ -1397,7 +1410,7 @@ export async function execute(
1397
1410
  }
1398
1411
  if (selection.backend === "claude") {
1399
1412
  // Claude btw always runs (and resumes) in the caller's workspace, even restricted.
1400
- if (request.feature === "btw" && !request.cwd?.trim()) {
1413
+ if ((request.feature === "btw" || request.feature === "review") && !request.cwd?.trim()) {
1401
1414
  return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1402
1415
  }
1403
1416
  if (!selection.model.trim() || (selection.effort !== undefined && !CLAUDE_EFFORTS.includes(selection.effort))) {