pi-bro 0.15.1 → 0.16.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 +7 -0
- package/README.md +44 -29
- package/backend.ts +226 -9
- package/bro.ts +499 -108
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-bro are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.16.0] - 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Claude Code execution for explain, show and advisor, selected independently per capability through `/bro config`. Explain/show disable tools and customizations; advisor runs fresh with workspace tools. Claude-backed BTW is explicitly unsupported in this release.
|
|
10
|
+
- Backend-tagged model/effort selections with atomic per-capability overrides. Existing settings remain Agy selections and are migrated to version 2 only when saved; Agy remains the default.
|
|
11
|
+
|
|
5
12
|
## [0.15.1] - 2026-09-22
|
|
6
13
|
|
|
7
14
|
### Changed
|
package/README.md
CHANGED
|
@@ -7,7 +7,8 @@ plain-language explanation — or open a sandboxed side conversation with
|
|
|
7
7
|
`pi-bro` is an extension for [Earendil Pi](https://github.com/earendil-works/pi).
|
|
8
8
|
It opens explanations in a separate modal and uses the
|
|
9
9
|
[Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`)
|
|
10
|
-
with your selected model.
|
|
10
|
+
with your selected model. Claude Code is also supported for explain, show and
|
|
11
|
+
advisor; Agy remains the default and is required for BTW.
|
|
11
12
|
|
|
12
13
|
## Quick start
|
|
13
14
|
|
|
@@ -32,7 +33,7 @@ Restart Pi or run `/reload`, then try:
|
|
|
32
33
|
Run `/bro doctor` after installation or whenever Bro is not working.
|
|
33
34
|
|
|
34
35
|
Explain, show, BTW and advisor share an internal execution layer; Agy remains
|
|
35
|
-
|
|
36
|
+
the default backend. Claude Code can be selected per capability in `/bro config`. Cancellation, host deadlines
|
|
36
37
|
and invalid execution streams terminate the subprocess group on POSIX, escalating
|
|
37
38
|
after a five-second grace period. Windows cleanup targets the direct child only;
|
|
38
39
|
descendant termination is not guaranteed. Unexpected signal exits are reported as
|
|
@@ -70,7 +71,7 @@ text directly captures a new source the same way.
|
|
|
70
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. |
|
|
71
72
|
| `/bro doctor` | Check Bro's settings, Agy installation, account, model, effort, and mode. |
|
|
72
73
|
| `/bro usage [--provider agy]` | Show current Agy resource limits. |
|
|
73
|
-
| `/bro model [id]` | View or choose the shared default
|
|
74
|
+
| `/bro model [id]` | View or choose the selected backend’s shared default model. |
|
|
74
75
|
| `/bro effort [low\|medium\|high]` | View or choose the shared default reasoning effort. |
|
|
75
76
|
| `/bro mode [brief\|balanced\|faithful]` | View or choose the explanation mode. |
|
|
76
77
|
| `/bro config` | Open an interactive settings screen for the shared default model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor) model and effort overrides. |
|
|
@@ -164,7 +165,7 @@ gives an `agy update` action when it is too old.
|
|
|
164
165
|
summary. Bro captures a harness-neutral snapshot — the executor's system
|
|
165
166
|
instructions, its active tools, and the conversation so far including tool
|
|
166
167
|
calls and results — and sends it, along with an optional `question` the
|
|
167
|
-
executor may pass, to a **fresh, standalone
|
|
168
|
+
executor may pass, to a **fresh, standalone process of the selected backend** for every
|
|
168
169
|
consultation. Nothing is resumed or reused across calls, including retries.
|
|
169
170
|
- **Instructed to investigate, not implement**: the advisor process has real
|
|
170
171
|
tool access in the workspace with permissions auto-approved
|
|
@@ -694,32 +695,46 @@ Bro creates this user-editable settings file when the extension loads:
|
|
|
694
695
|
~/.pi/agent/bro-settings.json
|
|
695
696
|
```
|
|
696
697
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
"model": "gemini-3.7-flash",
|
|
700
|
-
"effort": "low",
|
|
701
|
-
"mode": "balanced",
|
|
702
|
-
"showTurns": 1
|
|
703
|
-
}
|
|
704
|
-
```
|
|
705
|
-
|
|
706
|
-
`model` and `effort` are the **shared default**: explain, show, and btw all use
|
|
707
|
-
them unless a capability has its own override. An optional `overrides` object
|
|
708
|
-
adds per-capability overrides, each a full `{ "model": ..., "effort": ... }`
|
|
709
|
-
pair:
|
|
698
|
+
Existing flat model/effort files remain valid and select Agy. Explicit saves use
|
|
699
|
+
version 2 with backend-tagged selections:
|
|
710
700
|
|
|
711
701
|
```json
|
|
712
702
|
{
|
|
713
|
-
"
|
|
714
|
-
"effort": "low",
|
|
703
|
+
"version": 2,
|
|
704
|
+
"default": { "backend": "agy", "model": "gemini-3.7-flash", "effort": "low" },
|
|
715
705
|
"mode": "balanced",
|
|
716
706
|
"showTurns": 1,
|
|
717
707
|
"overrides": {
|
|
718
|
-
"
|
|
708
|
+
"explain": { "backend": "claude", "model": "sonnet", "effort": "medium" },
|
|
709
|
+
"advisor": { "backend": "claude", "model": "opus", "effort": "high" }
|
|
719
710
|
}
|
|
720
711
|
}
|
|
721
712
|
```
|
|
722
713
|
|
|
714
|
+
Each override is a complete backend/model/effort selection, never a field-by-field
|
|
715
|
+
merge. Omitted overrides inherit the shared default. Matching an override to the
|
|
716
|
+
default does not unpin it; select Default explicitly to restore inheritance.
|
|
717
|
+
|
|
718
|
+
### Claude Code
|
|
719
|
+
|
|
720
|
+
Install and authenticate `claude` independently (tested with Claude Code 2.1.281).
|
|
721
|
+
Bro uses its CLI account and billing route, not Pi provider credentials. Choose
|
|
722
|
+
Claude in `/bro config`; model aliases such as `sonnet` and `opus`, or explicit
|
|
723
|
+
model IDs, are passed to the CLI. Claude efforts are `default` (omit the flag),
|
|
724
|
+
`low`, `medium`, `high`, `xhigh`, and `max`; the chosen model/account must support
|
|
725
|
+
the requested combination. Runtime rejection is surfaced without fallback.
|
|
726
|
+
|
|
727
|
+
Explain/show use a scratch directory, safe mode, disabled tools, empty strict MCP
|
|
728
|
+
configuration, disabled skills and no session persistence. This is a tool/configuration
|
|
729
|
+
restriction, not an OS sandbox; built-in and managed Claude behavior can remain.
|
|
730
|
+
Advisor uses safe mode and a fresh workspace process with permissions bypassed;
|
|
731
|
+
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.
|
|
734
|
+
|
|
735
|
+
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.
|
|
737
|
+
|
|
723
738
|
Use `/bro model`, `/bro effort`, and `/bro mode` to update the shared default
|
|
724
739
|
and mode from Pi, `/bro config` to review or change the shared default and any
|
|
725
740
|
per-capability (explain/show/btw/advisor) overrides interactively, or edit the file
|
|
@@ -749,9 +764,9 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
749
764
|
### Configuration precedence
|
|
750
765
|
|
|
751
766
|
When resolving model and reasoning effort:
|
|
752
|
-
1. **Per-capability override**: If configured under `overrides.<capability>` (`explain`, `show`, `btw`, or `advisor`) in `bro-settings.json`, that capability pins its own
|
|
753
|
-
2. **Shared default**: If no override is set for that capability, it inherits
|
|
754
|
-
3. **Catalog normalization**: Bro normalizes the resolved `{ model, effort }` against Agy's installed model catalog (mapping suffixed variant IDs and handling fixed-effort models).
|
|
767
|
+
1. **Per-capability override**: If configured under `overrides.<capability>` (`explain`, `show`, `btw`, or `advisor`) in `bro-settings.json`, that capability pins its own complete backend/model/effort selection and ignores the shared default.
|
|
768
|
+
2. **Shared default**: If no override is set for that capability, it inherits `default` in version-2 settings (root model/effort in legacy settings).
|
|
769
|
+
3. **Catalog normalization**: For Agy selections, Bro normalizes the resolved `{ model, effort }` against Agy's installed model catalog (mapping suffixed variant IDs and handling fixed-effort models).
|
|
755
770
|
4. **Initial file creation only**: `PI_BRO_MODEL` selects the initial default model only when Bro creates a missing `bro-settings.json` file. It has no effect once the file exists.
|
|
756
771
|
|
|
757
772
|
When resolving turn count for `/bro show`:
|
|
@@ -799,7 +814,7 @@ run `/bro doctor` for the exact problem.
|
|
|
799
814
|
- **External requests**: Bro sends the latest completed assistant response,
|
|
800
815
|
pasted text, extracted document text, extracted webpage text, or recent
|
|
801
816
|
session conversation text (tool calls, tool results, reasoning, and images
|
|
802
|
-
omitted) to
|
|
817
|
+
omitted) to the selected backend and its configured model provider.
|
|
803
818
|
- **Side conversation requests**: `/bro btw` sends your side questions and, on
|
|
804
819
|
the first turn, the seeded main-session conversation text to Agy. In `--full`
|
|
805
820
|
mode the side agent additionally reads the workspace.
|
|
@@ -830,14 +845,14 @@ run `/bro doctor` for the exact problem.
|
|
|
830
845
|
URLs whose query string contains secrets.
|
|
831
846
|
- **Web extraction**: Bro parses downloaded HTML locally without executing page
|
|
832
847
|
scripts or loading page subresources. It sends the extracted readable text,
|
|
833
|
-
including links preserved in that text, to
|
|
848
|
+
including links preserved in that text, to the selected backend; it does not separately send
|
|
834
849
|
the requested URL or raw page HTML. The URL, captured text, and explanation
|
|
835
850
|
remain in process memory only and clear with the existing `/bro open` cache.
|
|
836
851
|
- **Show diagrams**: When a show reply ends in one self-contained HTML block,
|
|
837
852
|
Bro writes it to `/tmp/pi-bro-<uid>/bro-show-<hash>.html` with a restrictive
|
|
838
853
|
Content-Security-Policy, and opens it in your browser only when you press
|
|
839
854
|
**O**. **C** copies the full reply, including the HTML.
|
|
840
|
-
- **Provider data**:
|
|
855
|
+
- **Provider data**: The selected CLI backend and your model provider may retain logs and request data
|
|
841
856
|
according to their own settings and privacy policies.
|
|
842
857
|
- **Clipboard**: Pressing **C** copies the text to your system clipboard, where
|
|
843
858
|
your operating system or clipboard manager may retain it.
|
|
@@ -845,7 +860,7 @@ run `/bro doctor` for the exact problem.
|
|
|
845
860
|
instructions, active tool list (excluding `bro_advisor`), ordered
|
|
846
861
|
conversation history including tool calls and tool results (unlike Show, which
|
|
847
862
|
omits them), human steering brief, and the executor's optional question to
|
|
848
|
-
|
|
863
|
+
the selected backend and its configured model provider. Reasoning and image bodies are
|
|
849
864
|
omitted with explicit markers (`[reasoning omitted]`, `[image omitted]`).
|
|
850
865
|
- **Advisor tool execution & safety boundary**: The advisor process runs
|
|
851
866
|
directly in your workspace (`cwd`) with auto-approved permissions
|
|
@@ -867,7 +882,7 @@ extracted, copy its content into a supported text file or save it as a PDF and
|
|
|
867
882
|
use `/bro file`. If a PDF contains only scanned images, run OCR with another
|
|
868
883
|
tool before giving it to Bro.
|
|
869
884
|
|
|
870
|
-
-
|
|
885
|
+
- Supports Agy for all capabilities and Claude Code for explain/show/advisor. Claude BTW is not yet supported; unsupported selections fail without fallback.
|
|
871
886
|
- Document input supports `.md`, `.markdown`, `.txt`, `.pdf`, and `.docx` only;
|
|
872
887
|
it does not perform OCR.
|
|
873
888
|
- Webpage input supports one public HTML page, up to 5 MiB downloaded and
|
|
@@ -906,7 +921,7 @@ npm test
|
|
|
906
921
|
pi --tui-mode fullscreen -e ./bro.ts
|
|
907
922
|
```
|
|
908
923
|
|
|
909
|
-
The smoke test uses a fake `agy`, so
|
|
924
|
+
The smoke test uses a fake `agy`, and Claude adapter tests use a fake `claude`, so automated tests do not call an external model. It
|
|
910
925
|
verifies command routing, document and URL safety boundaries, HTML
|
|
911
926
|
extraction, show capture (conversation text only, tool calls and results
|
|
912
927
|
absent), and HTML-diagram handling, healthy and broken setup handling,
|
package/backend.ts
CHANGED
|
@@ -5,14 +5,18 @@ import { join } from "node:path";
|
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
|
|
7
7
|
// Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
|
|
8
|
-
// This
|
|
9
|
-
//
|
|
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
|
|
10
10
|
// single-attempt cleanup. Feature code (bro.ts) keeps retries, UI, source/session capture and
|
|
11
11
|
// settings.
|
|
12
12
|
|
|
13
13
|
export type BackendFeature = "explain" | "show" | "btw" | "advisor";
|
|
14
14
|
export type BackendAccess = "restricted" | "workspace-full";
|
|
15
15
|
export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
|
|
16
|
+
export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
export type BackendSelection =
|
|
18
|
+
| ({ backend?: "agy" } & AgySelection)
|
|
19
|
+
| { backend: "claude"; model: string; effort?: (typeof CLAUDE_EFFORTS)[number] };
|
|
16
20
|
export type BackendContinuation = { id: string };
|
|
17
21
|
export type BackendProgress = { kind: "text"; text: string } | { kind: "activity"; label: string; timestamp: number };
|
|
18
22
|
export type BackendOnProgress = (progress: BackendProgress) => void;
|
|
@@ -61,14 +65,17 @@ export function agySelection(pair: { model: string; effort: "default" | "low" |
|
|
|
61
65
|
};
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
function processStartMessage(processError: NodeJS.ErrnoException): string {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
function processStartMessage(processError: NodeJS.ErrnoException, cli = "Agy"): string {
|
|
69
|
+
if (processError.code === "ENOENT") {
|
|
70
|
+
return cli === "Agy"
|
|
71
|
+
? "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`.";
|
|
73
|
+
}
|
|
74
|
+
return `${cli} could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null): string {
|
|
71
|
-
return withDoctor(
|
|
77
|
+
function unexpectedSignalMessage(exitSignal: NodeJS.Signals | null, cli = "Agy"): string {
|
|
78
|
+
return withDoctor(`${cli} exited unexpectedly${exitSignal ? ` (signal ${exitSignal})` : ""}, not from a request Bro made.`);
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
// Sends to the whole POSIX process group when possible so a misbehaving grandchild dies too, not
|
|
@@ -481,6 +488,207 @@ async function executeAdvisorStdin(
|
|
|
481
488
|
return { status: "success", text };
|
|
482
489
|
}
|
|
483
490
|
|
|
491
|
+
|
|
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";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
type ClaudeEvent = {
|
|
498
|
+
type?: unknown;
|
|
499
|
+
subtype?: unknown;
|
|
500
|
+
is_error?: unknown;
|
|
501
|
+
result?: unknown;
|
|
502
|
+
parent_tool_use_id?: unknown;
|
|
503
|
+
stop_reason?: unknown;
|
|
504
|
+
terminal_reason?: unknown;
|
|
505
|
+
event?: { type?: unknown; delta?: { type?: unknown; text?: unknown } };
|
|
506
|
+
message?: { content?: unknown };
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Every Claude run is fresh (never resumed, nothing persisted) under --safe-mode, which disables
|
|
510
|
+
// CLAUDE.md, skills, plugins, hooks and MCP servers but keeps the user's auth (--bare would break
|
|
511
|
+
// OAuth). The prompt goes over stdin. explain/show additionally run tool-less in a scratch cwd;
|
|
512
|
+
// advisor runs in the caller's workspace with tools auto-approved. Only the terminal `result`
|
|
513
|
+
// event is authoritative; streamed text_delta events are progress only, and thinking is dropped.
|
|
514
|
+
async function executeClaude(
|
|
515
|
+
request: BackendRequest,
|
|
516
|
+
selection: { model: string; effort?: string },
|
|
517
|
+
signal: AbortSignal,
|
|
518
|
+
onProgress: BackendOnProgress | undefined,
|
|
519
|
+
killEscalationMs: number,
|
|
520
|
+
deadlineMsOverride: number | undefined,
|
|
521
|
+
): Promise<BackendOutcome> {
|
|
522
|
+
const isAdvisor = request.feature === "advisor";
|
|
523
|
+
const deadlineMs = deadlineMsOverride ?? (isAdvisor ? 610_000 : 125_000);
|
|
524
|
+
const action = isAdvisor ? "complete the advisor consultation" : "simplify the response";
|
|
525
|
+
|
|
526
|
+
if (signal.aborted) return { status: "cancelled", message: "Canceled." };
|
|
527
|
+
const runDirectory = isAdvisor ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
528
|
+
if (signal.aborted) {
|
|
529
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
530
|
+
return { status: "cancelled", message: "Canceled." };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
const child = spawn(
|
|
535
|
+
"claude",
|
|
536
|
+
[
|
|
537
|
+
"-p",
|
|
538
|
+
"--safe-mode",
|
|
539
|
+
"--no-session-persistence",
|
|
540
|
+
"--disable-slash-commands",
|
|
541
|
+
"--output-format",
|
|
542
|
+
"stream-json",
|
|
543
|
+
"--verbose",
|
|
544
|
+
"--include-partial-messages",
|
|
545
|
+
...(isAdvisor
|
|
546
|
+
? ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--dangerously-skip-permissions"]
|
|
547
|
+
: ["--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]),
|
|
548
|
+
"--model",
|
|
549
|
+
selection.model,
|
|
550
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
551
|
+
],
|
|
552
|
+
{
|
|
553
|
+
cwd: isAdvisor ? request.cwd : runDirectory,
|
|
554
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
555
|
+
windowsHide: true,
|
|
556
|
+
detached: process.platform !== "win32",
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
|
|
561
|
+
let processError: Error | undefined;
|
|
562
|
+
let stderr = "";
|
|
563
|
+
let partial = "";
|
|
564
|
+
let final: string | undefined;
|
|
565
|
+
let terminalError: string | undefined;
|
|
566
|
+
let protocolError: string | undefined;
|
|
567
|
+
let stdoutBuffer = "";
|
|
568
|
+
|
|
569
|
+
child.stderr?.setEncoding("utf8");
|
|
570
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
571
|
+
stderr += chunk;
|
|
572
|
+
});
|
|
573
|
+
child.once("error", (error) => {
|
|
574
|
+
processError = error;
|
|
575
|
+
});
|
|
576
|
+
child.stdin?.on("error", () => {
|
|
577
|
+
// claude exiting before it reads stdin is reported through the close/error path below.
|
|
578
|
+
});
|
|
579
|
+
child.stdin?.end(request.prompt);
|
|
580
|
+
|
|
581
|
+
const handleLine = (line: string) => {
|
|
582
|
+
if (!line.trim() || attempt.causeOf()) return;
|
|
583
|
+
let event: ClaudeEvent;
|
|
584
|
+
try {
|
|
585
|
+
event = JSON.parse(line) as ClaudeEvent;
|
|
586
|
+
} catch {
|
|
587
|
+
throw new Error("Claude emitted invalid stream-json output.");
|
|
588
|
+
}
|
|
589
|
+
const topLevel = event.parent_tool_use_id === undefined || event.parent_tool_use_id === null;
|
|
590
|
+
if (!isAdvisor && topLevel && event.type === "stream_event" && event.event?.type === "content_block_delta") {
|
|
591
|
+
const delta = event.event.delta;
|
|
592
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
|
|
593
|
+
partial += delta.text;
|
|
594
|
+
onProgress?.({ kind: "text", text: partial });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (isAdvisor && topLevel && event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
598
|
+
for (const block of event.message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
|
|
599
|
+
const label =
|
|
600
|
+
block?.type === "tool_use" && typeof block.name === "string"
|
|
601
|
+
? block.name.trim()
|
|
602
|
+
: block?.type === "text" && typeof block.text === "string"
|
|
603
|
+
? block.text.split("\n").find((text) => text.trim())?.trim()
|
|
604
|
+
: undefined;
|
|
605
|
+
if (label) onProgress?.({ kind: "activity", label, timestamp: Date.now() });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (event.type !== "result" || !topLevel) return;
|
|
609
|
+
if (event.subtype === "success" && event.is_error === false && (event.stop_reason !== "end_turn" || (event.terminal_reason !== undefined && event.terminal_reason !== "completed"))) {
|
|
610
|
+
terminalError ??= `Claude did not complete the answer (stop reason: ${String(event.stop_reason)}, terminal reason: ${String(event.terminal_reason)}).`;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// A result ends a model turn, not necessarily the stream; the first failure is latched and
|
|
614
|
+
// otherwise the latest successful result wins once the process exits cleanly.
|
|
615
|
+
if (event.subtype === "success" && event.is_error === false && typeof event.result === "string") {
|
|
616
|
+
final = event.result;
|
|
617
|
+
} else {
|
|
618
|
+
const detail =
|
|
619
|
+
typeof event.subtype === "string" && event.subtype !== "success"
|
|
620
|
+
? event.subtype
|
|
621
|
+
: typeof event.result === "string" && event.result.trim()
|
|
622
|
+
? event.result.trim()
|
|
623
|
+
: "turn failed";
|
|
624
|
+
terminalError ??= `Claude failed: ${detail}`;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
child.stdout?.setEncoding("utf8");
|
|
629
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
630
|
+
stdoutBuffer += chunk;
|
|
631
|
+
const parts = stdoutBuffer.split(/\r?\n/);
|
|
632
|
+
stdoutBuffer = parts.pop() ?? "";
|
|
633
|
+
if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
|
|
634
|
+
protocolError ??= `Claude emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
|
|
635
|
+
stdoutBuffer = "";
|
|
636
|
+
attempt.stop("protocol");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
for (const line of parts) {
|
|
640
|
+
try {
|
|
641
|
+
handleLine(line);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
protocolError ??= errorMessage(error);
|
|
644
|
+
attempt.stop("protocol");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const { code, exitSignal } = await attempt.closed;
|
|
651
|
+
attempt.dispose();
|
|
652
|
+
if (stdoutBuffer.trim()) {
|
|
653
|
+
try {
|
|
654
|
+
handleLine(stdoutBuffer);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
protocolError ??= errorMessage(error);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const partialText = partial || undefined;
|
|
661
|
+
const cause = attempt.causeOf();
|
|
662
|
+
if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText };
|
|
663
|
+
if (cause === "timeout") {
|
|
664
|
+
const during = isAdvisor ? "during the advisor consultation" : "while simplifying the response";
|
|
665
|
+
return { status: "timeout", message: `Claude timed out ${during}. Run \`/bro doctor\` for setup help.`, partialText };
|
|
666
|
+
}
|
|
667
|
+
if (protocolError) return { status: "failure", message: withDoctor(protocolError), partialText };
|
|
668
|
+
if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException, "Claude") };
|
|
669
|
+
if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal, "Claude"), partialText };
|
|
670
|
+
if (terminalError) return { status: "failure", message: withDoctor(terminalError), partialText };
|
|
671
|
+
if (code !== 0) {
|
|
672
|
+
const detail = stderr.trim();
|
|
673
|
+
return {
|
|
674
|
+
status: "failure",
|
|
675
|
+
message: detail
|
|
676
|
+
? `Claude could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`
|
|
677
|
+
: `Claude could not ${action}. Make sure Claude Code is installed and signed in, then run \`/bro doctor\`.`,
|
|
678
|
+
partialText,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (final === undefined) {
|
|
682
|
+
return { status: "failure", message: withDoctor(`Claude exited without a result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), partialText };
|
|
683
|
+
}
|
|
684
|
+
const text = final.trim();
|
|
685
|
+
if (!text) return { status: "failure", message: withDoctor("Claude returned no final answer."), partialText };
|
|
686
|
+
return { status: "success", text };
|
|
687
|
+
} finally {
|
|
688
|
+
if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
484
692
|
// Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
|
|
485
693
|
// e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
|
|
486
694
|
// host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
|
|
@@ -488,7 +696,7 @@ async function executeAdvisorStdin(
|
|
|
488
696
|
// callers never override the deadline or kill-escalation delay.
|
|
489
697
|
export async function execute(
|
|
490
698
|
request: BackendRequest,
|
|
491
|
-
selection:
|
|
699
|
+
selection: BackendSelection,
|
|
492
700
|
signal: AbortSignal,
|
|
493
701
|
onProgress?: BackendOnProgress,
|
|
494
702
|
options?: BackendExecuteOptions,
|
|
@@ -500,6 +708,15 @@ export async function execute(
|
|
|
500
708
|
(request.feature !== "btw" && request.continuation)
|
|
501
709
|
) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
|
|
502
710
|
const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
|
|
711
|
+
if (selection.backend === "claude") {
|
|
712
|
+
if (!backendSupports("claude", request.feature)) {
|
|
713
|
+
return { status: "failure", message: "Claude does not support /bro btw yet; switch btw back to Agy in `/bro config`." };
|
|
714
|
+
}
|
|
715
|
+
if (!selection.model.trim() || (selection.effort !== undefined && !CLAUDE_EFFORTS.includes(selection.effort))) {
|
|
716
|
+
return { status: "failure", message: "Unsupported Claude selection: check the model and effort (low, medium, high, xhigh or max)." };
|
|
717
|
+
}
|
|
718
|
+
return executeClaude(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
719
|
+
}
|
|
503
720
|
if (request.feature === "advisor") {
|
|
504
721
|
return executeAdvisorStdin(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
|
|
505
722
|
}
|