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 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
- its only backend and existing settings are unchanged. Cancellation, host deadlines
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 Agy model. |
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 Agy process** for every
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
- ```json
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
- "model": "gemini-3.7-flash",
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
- "show": { "model": "gemini-3.7-pro", "effort": "high" }
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 `{ "model": ..., "effort": ... }` pair and ignores the shared default.
753
- 2. **Shared default**: If no override is set for that capability, it inherits the root `model` and `effort` in `bro-settings.json`.
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 Agy and its configured model provider.
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 Agy; it does not separately send
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**: Agy and your model provider may retain logs and request 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
- Agy and your configured model provider. Reasoning and image bodies are
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
- - Uses Agy as its only provider.
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 it does not call an external model. It
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 is the Agy-only implementation of docs/plans/2026-09-22-shared-backend-design.md: it owns
9
- // Agy CLI selection, process invocation, progress/outcome normalization, continuation, and
8
+ // This implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
9
+ // Claude Code CLI (explain/show/advisor; no btw yet): 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
- return processError.code === "ENOENT"
66
- ? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
67
- : `Agy could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
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(`Agy exited unexpectedly${exitSignal ? ` (signal ${exitSignal})` : ""}, not from a request Bro made.`);
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: AgySelection,
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
  }