pi-bro 0.17.0 → 0.18.1

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/bro.ts CHANGED
@@ -63,7 +63,9 @@ type BroSource = { text: string; label?: string };
63
63
  type BroResult = { source: BroSource; text: string; model?: string };
64
64
  type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string };
65
65
  type BtwTurn = { question: string; answer: string };
66
- type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string };
66
+ // `context` keeps the main-session seed so a fresh native session can be reseeded with the whole
67
+ // thread; `sessionFull` is the access mode the native session last ran in.
68
+ type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; context?: string; sessionFull?: boolean };
67
69
  const EFFORTS = ["default", "low", "medium", "high"] as const;
68
70
  const BACKENDS = ["agy", "claude", "grok"] as const;
69
71
  type BackendName = (typeof BACKENDS)[number];
@@ -122,11 +124,9 @@ const COMMANDS = [
122
124
  { value: "url", label: "url", description: "Explain a public webpage" },
123
125
  { value: "open", label: "open", description: "Reopen the last explanation" },
124
126
  { value: "doctor", label: "doctor", description: "Check whether Bro is ready" },
125
- { value: "model", label: "model", description: "View or choose the shared default model" },
126
- { value: "effort", label: "effort", description: "View or choose the shared default reasoning effort" },
127
127
  { value: "show", label: "show", description: "Draw what happened in recent session turns as shapes" },
128
128
  { value: "mode", label: "mode", description: "View or choose explanation mode (brief, balanced, faithful)" },
129
- { value: "btw", label: "btw", description: "Open a side conversation (context-only intent; --full invites workspace access)" },
129
+ { value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
130
130
  { value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
131
131
  { value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
132
132
  { value: "advisor-steer", label: "advisor-steer", description: "View, edit, save, or clear the advisor's persistent steering brief" },
@@ -1092,7 +1092,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1092
1092
  if (defaultBackend === "claude") {
1093
1093
  pass("Selected model", `claude \`${settings.model}\``);
1094
1094
  if (!isClaudeEffort(settings.effort)) {
1095
- fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1095
+ fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1096
1096
  } else if (settings.effort === "default") {
1097
1097
  pass("Reasoning effort", "built into the selected model");
1098
1098
  } else {
@@ -1101,7 +1101,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1101
1101
  } else if (defaultBackend === "grok") {
1102
1102
  pass("Selected model", `grok \`${settings.model}\``);
1103
1103
  if (!isGrokEffort(settings.effort)) {
1104
- fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1104
+ fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1105
1105
  } else if (settings.effort === "default") {
1106
1106
  pass("Reasoning effort", "built into the selected model");
1107
1107
  } else {
@@ -1110,7 +1110,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1110
1110
  } else if (models) {
1111
1111
  const current = resolveCatalogSettings(settings, models);
1112
1112
  if (!current.family) {
1113
- fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro model\` to choose another.`);
1113
+ fail("Selected model", `\`${settings.model}\` is unavailable. Run \`/bro config\` to choose another.`);
1114
1114
  } else {
1115
1115
  pass("Selected model", `agy \`${current.family.id}\``);
1116
1116
  const effort = current.settings.effort;
@@ -1119,7 +1119,7 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1119
1119
  } else if (effort !== "default" && current.family.efforts.includes(effort as AgyEffort)) {
1120
1120
  pass("Reasoning effort", effort);
1121
1121
  } else {
1122
- fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro effort\` to choose another.`);
1122
+ fail("Reasoning effort", `\`${effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1123
1123
  }
1124
1124
  }
1125
1125
  }
@@ -1130,10 +1130,6 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1130
1130
  const backend = capabilityBackend(settings, capability);
1131
1131
  const pair = capabilityPair(settings, capability);
1132
1132
  if (backend === "claude") {
1133
- if (capability === "btw") {
1134
- fail(label, "`btw` is not supported on the Claude backend. Run `/bro config` to give it an Agy model.");
1135
- continue;
1136
- }
1137
1133
  if (!isClaudeEffort(pair.effort)) {
1138
1134
  fail(label, `\`${pair.effort}\` is unsupported for claude \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1139
1135
  continue;
@@ -2307,101 +2303,62 @@ export function helpText(settings?: BroSettings, settingsError?: string): string
2307
2303
  const settingsSummary = settings
2308
2304
  ? `- **Backend:** ${settings.backend ?? "agy"}\n- **Model:** \`${settings.model}\`\n- **Reasoning effort:** ${settings.effort === "default" ? "built into the selected model" : settings.effort}\n- **Mode:** ${settings.mode}\n- **Show turns:** ${settings.showTurns}${overrideLines.length ? `\n${overrideLines.join("\n")}` : ""}`
2309
2305
  : `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
2310
- const advisorBackend = settings ? capabilityBackend(settings, "advisor") : "agy";
2311
- const advisorProcess = advisorBackend === "claude" ? "Claude process" : advisorBackend === "grok" ? "Grok process" : "Agy process";
2312
- const advisorName = advisorBackend === "claude" ? "Claude" : advisorBackend === "grok" ? "Grok" : "Agy";
2313
2306
  return `# Bro
2314
2307
 
2315
- Bro explains a dense assistant reply, pasted text, local document, or public webpage in plain language, draws recent session turns as shapes, or opens a separate side conversation with \`/bro btw\` — without adding anything to Pi's conversation.
2308
+ Quick reference. The README is the full user guide: https://github.com/tranhoangnguyen03/pi-bro#readme
2316
2309
 
2317
- ## Explain
2310
+ ## Explain and show
2318
2311
 
2319
2312
  - \`/bro\` — explain the latest completed assistant reply
2320
2313
  - \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
2321
- - \`/bro file <path>\` — explain a Markdown, text, PDF, or DOCX file
2314
+ - \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
2322
2315
  - \`/bro url <url>\` — explain one public webpage
2323
- - \`/bro open\` — reopen the latest explanation
2324
- - \`/bro show [n-turns] [query]\` — draw the last few session turns (default 1) as shapes, from user and assistant conversation text only (tool calls, tool results, reasoning, and images are omitted); add a query to steer what the shapes focus on
2325
-
2326
- Any other input is the source itself: a lone URL explains that webpage, an existing workspace file with a supported extension explains that file, and anything else is explained as pasted text. Quoted paths with spaces are routed too when the file exists.
2327
-
2328
- Press **R** to simplify the captured source again. Run a new \`/bro text\`, \`/bro file\`, \`/bro url\`, or \`/bro show\` command — or give \`/bro\` the input directly — to capture a new source.
2329
-
2330
- ## Check and configure
2331
-
2332
- - \`/bro doctor\` — check settings, backends, account, model, effort, and mode (per-feature backend/model/effort)
2333
- - \`/bro model [id]\` — view or choose the shared default model (Agy catalog, sonnet/opus/explicit IDs on Claude, grok-4.7/grok-4.7-build-fast/explicit IDs on Grok)
2334
- - \`/bro effort [low|medium|high|xhigh|max]\` — view or choose the shared default reasoning effort (xhigh on Claude/Grok, max on Claude only)
2335
- - \`/bro mode [brief|balanced|faithful]\` — view or choose explanation mode
2336
- - \`/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) backend, model, and effort overrides. Changes save immediately; Esc on a picker cancels without changing anything, Esc on the screen closes it and keeps whatever was already saved.
2337
-
2338
- \`/bro model\` and \`/bro effort\` always change the shared default that explain, show, btw, and advisor fall back to when they have no override. Use \`/bro config\` to give one of them its own backend, model, or effort. \`btw\` runs on Agy or Grok (Claude continuation is pending).
2316
+ - \`/bro <input>\` — a lone URL, an existing supported file, or anything else as pasted text
2317
+ - \`/bro open\` — reopen the latest explanation without a new request
2318
+ - \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
2339
2319
 
2340
2320
  ## Side conversation
2341
2321
 
2342
- - \`/bro btw [--fresh] [--full] [question]\` — open a side conversation. Conversation-only intent by default (Agy sandbox controls; Grok prompt request, not enforced); add \`--full\` to let it read and edit the workspace, and \`--fresh\` to start without main-session context. Reopening preserves the thread's access mode (even with \`--fresh\`); use \`--sandbox\` to return to sandbox mode. Changing access mode starts a new thread. Inside the side thread, type questions and press Enter (empty Enter re-asks); exact commands \`/copy\` and \`/copy-all\` copy the latest answer or full thread to the system clipboard; exact commands \`/insert\` and \`/insert-all\` insert into the main editor without submitting (use \`/insert!\` or \`/insert-all!\` to replace an existing draft); \`/retry\` re-asks the last question; \`/clear\` resets the thread. Any other input is sent as a question. Esc closes.
2322
+ - \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
2323
+ - Inside the modal: Enter asks (empty Enter re-asks); \`/mode\` toggles conversation-only / full permission (read and edit the workspace) and keeps the thread; \`/copy\` and \`/copy-all\` copy to the clipboard; \`/insert\` and \`/insert-all\` insert into an empty main editor without submitting; \`/retry\` re-asks; \`/clear\` resets. Any other text is sent as a question. Esc closes.
2343
2324
 
2344
2325
  ## Advisor
2345
2326
 
2346
- - \`bro_advisor\` — a tool the executor agent can voluntarily call mid-task for a second opinion from a fresh ${advisorProcess} before or after a non-trivial decision. It is registered like any other tool and has no on/off switch of its own; whether the executor can actually call it depends entirely on this host's own tool restrictions
2347
- - \`/bro advisor\` — a quick notice of whether \`bro_advisor\` is available right now, pointing at \`/bro config\`, \`/bro advisor-steer\`, and \`/bro doctor\`
2348
- - \`/bro advisor-steer\` — open an editor for one persistent steering brief the advisor always sees. **Ctrl+S** saves, **Enter**/**Shift+Enter** insert newlines, **Ctrl+K** clears the saved brief and draft, **Ctrl+C** copies the full draft, and **Esc** closes without saving unsaved edits
2349
- - \`/bro doctor\` — the full advisor diagnostic: whether this host exposes and activates \`bro_advisor\`, its resolved model/effort, steering presence, and backend compatibility
2327
+ - \`bro_advisor\` — a tool the executor agent may call for a second opinion from a fresh backend process with real, auto-approved workspace access; it is told to advise, not edit, but that is not enforced
2328
+ - \`/bro advisor\` — whether \`bro_advisor\` is available right now
2329
+ - \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
2330
+
2331
+ ## Configure and check
2350
2332
 
2351
- Each consultation is a fresh, standalone ${advisorProcess} — never resumed, never looping, never automatically triggered. Bro captures the context snapshot (system instructions, active tools, and the conversation so far including tool calls and results) automatically; the executor never has to assemble one. The advisor has real tool access in the workspace, running with permissions auto-approved, so it can verify claims itself; it is instructed to only return advice and leave edits to the executor, but that instruction is behavioral rather than an enforced sandbox constraint. The steering brief persists in the session (not sent to the model) and is restored on resume or reload; forking a session inherits it, and edits after the fork are independent of the original branch.
2333
+ - \`/bro config\` — shared default and per-capability (explain/show/btw/advisor) backend, model, and effort; explain mode; show turns. Changes save immediately.
2334
+ - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
2335
+ - \`/bro doctor\` — check settings, prompt, and every selected backend without running a model turn
2352
2336
 
2353
2337
  ## Current settings
2354
2338
 
2355
2339
  ${settingsSummary}
2356
2340
 
2357
- Saved in \`${SETTINGS_FILE}\`. Use the commands above, configure interactively via \`/bro config\`, or edit the file directly. Changes apply to future explanations. \`showTurns\` can be configured interactively in \`/bro config\` or edited directly in \`${SETTINGS_FILE}\`, and overridden per run with \`/bro show <n-turns>\`. Add a query after the count — or on its own, e.g. \`/bro show what changed in the auth flow\` — to steer what the shapes focus on.
2341
+ Saved in \`${SETTINGS_FILE}\`.
2358
2342
 
2359
2343
  ## Explanation modes
2360
2344
 
2361
2345
  - brief — the main point and next action, with no fixed word target
2362
2346
  - balanced — default; material detail with clearer structure
2363
2347
  - faithful — closest to the source, with no fixed word limit
2364
- /bro show uses its own built-in draw prompt; the modes and \`bro-prompt.md\` do not affect it.
2365
- If \`${PROMPT_FILE}\` exists and is valid, the selected mode stays saved but inactive because the custom prompt fully overrides it. Remove or rename \`bro-prompt.md\` to use the saved built-in mode again.
2348
+
2349
+ A valid \`${PROMPT_FILE}\` (with \`{{response}}\` exactly once) overrides the modes; the saved mode stays inactive until you remove or rename it. Show always uses its own prompt.
2366
2350
 
2367
2351
  ## Controls
2368
2352
 
2369
- - **Mouse wheel / trackpad** — scroll
2370
- - **↑ / ↓** — scroll
2353
+ - **Mouse wheel / trackpad**, **↑ / ↓** — scroll
2371
2354
  - **C** — copy the full explanation
2372
- - **R** — repeat the current action\n- **O** — open the HTML diagram when a show reply contains one
2355
+ - **R** — repeat the current action
2356
+ - **O** — open the HTML diagram when a show reply contains one
2373
2357
  - **Esc** — close, or cancel while Bro is working
2374
2358
 
2375
- Bro temporarily captures mouse input while the modal is open. Native mouse selection may be unavailable or extend outside the modal; press **C** to copy everything reliably.
2376
-
2377
- ## Important limits
2378
-
2379
- - Documents must be inside the current workspace, are limited to 10 MiB and 100,000 extracted characters, and must be \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\`. Scanned PDFs need OCR first.
2380
- - Web input is limited to one public HTML page. Bro cannot sign in, run page JavaScript, bypass paywalls or blocks, follow pagination, or understand images and video.
2381
- - If a webpage fails, copy it into a text file or save it as a PDF, then use \`/bro file\`.
2382
- - Show draws only what already happened in this session — the conversation text of the last few turns, with tool calls, tool results, reasoning, and images always omitted — and is requested not to investigate the repository; Grok retains normal tools, so this is not enforced isolation. On a remote or headless session with no display, pressing **O** reports a failure instead of opening the diagram.
2383
- - Show reflects what was reported in the conversation, not independent verification against the actual code or system state.
2384
- - Btw threads are memory-only and do not survive reloads or restarts. A turn is capped at 2 minutes in sandbox mode and 10 minutes in full mode; the side conversation resumes through Agy \`--conversation\` or Grok \`--resume\`.
2385
- - Advisor consultations run with real tool access and auto-approved permissions (Grok: \`--sandbox off --permission-mode bypassPermissions\`; Agy/Claude: \`--dangerously-skip-permissions\`) — there is no enforced read-only isolation, only the advisor's own behavioral instructions to advise rather than implement. On invocation failure (not a completed answer), Bro retries with the identical snapshot, steering, and question: once after 5 seconds, once more after 10 seconds, then returns ${advisorName}'s own diagnostic as the failure.
2386
-
2387
- ## Privacy and safety
2388
-
2389
- Bro sends the selected assistant reply, pasted text, locally extracted document or webpage text, or recent session conversation text (tool calls, tool results, reasoning, and images omitted) to the selected backend and its model provider. They may retain request data under their own policies.
2390
-
2391
- Bro never adds the explanation to Pi's conversation, session file, or main-agent context. The captured source and latest explanation stay in process memory until you change sessions, reload extensions, or exit Pi.
2392
-
2393
- Bro asks explain/show backends to use supplied context; Grok retains tool authority, so this is behavioral rather than enforced. \`/bro btw\` uses Agy sandbox controls or Grok conversation-only prompt instructions by default; with \`--full\` it can read and edit the workspace, so use \`--full\` only when you want the side conversation to touch your project.
2394
- For webpages, it connects directly to the site without browser cookies; the site sees your IP address and Bro's user agent. Do not use private or signed URLs.
2359
+ ## Privacy
2395
2360
 
2396
- Doctor checks contact only the selected backends, but never send source text or run a model turn. Pressing **C** sends the explanation to your system clipboard.
2397
-
2398
- Each advisor consultation sends the executor's system instructions, active tool list, ordered conversation (including tool calls and results, since the advisor needs to verify claims), your steering brief, and the executor's optional question to the selected backend and its model provider; the advisor process itself can read and edit the workspace with no permission prompts. The steering brief is stored as session-only extension data — never added to the main conversation Pi or the model sees; the advisor tool has no separate activation state.
2399
-
2400
- ## Custom prompt
2401
-
2402
- Create or edit \`${PROMPT_FILE}\` and include \`{{response}}\` exactly once. Bro reads it on the next explanation and never modifies it. Existing valid custom prompts continue working unchanged.
2403
-
2404
- A valid custom prompt fully overrides all built-in mode instructions. \`/bro mode\` still changes the saved mode, but that mode remains inactive until you remove or rename \`bro-prompt.md\`. An invalid custom prompt blocks explanations; run \`/bro doctor\` for the exact problem.`;
2361
+ Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Nothing is added to Pi's conversation unless you insert it. Access controls differ by backend; see the README.`;
2405
2362
  }
2406
2363
 
2407
2364
  // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
@@ -2713,35 +2670,52 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
2713
2670
  );
2714
2671
  }
2715
2672
 
2716
- export function parseBtwArguments(value: string): { fresh: boolean; full?: boolean; question: string; invalid?: string } {
2717
- let rest = value.trim();
2718
- let fresh = false;
2719
- let full: boolean | undefined;
2720
- while (rest.startsWith("--")) {
2721
- const space = rest.search(/\s/);
2722
- const token = space === -1 ? rest : rest.slice(0, space);
2723
- if (token === "--fresh") fresh = true;
2724
- else if (token === "--full") full = true;
2725
- else if (token === "--sandbox") full = false;
2726
- else return { fresh, full, question: "", invalid: `Unknown /bro btw flag: ${token}` };
2727
- rest = space === -1 ? "" : rest.slice(space).replace(/^\s+/, "");
2673
+ export function parseBtwArguments(value: string): { question: string; invalid?: string } {
2674
+ const rest = value.trim();
2675
+ if (!rest.startsWith("--")) return { question: rest };
2676
+ const token = rest.split(/\s/, 1)[0]!;
2677
+ if (token === "--full" || token === "--sandbox") {
2678
+ return { question: "", invalid: `${token} was removed. Open /bro btw and type /mode to switch between conversation-only and full permission.` };
2728
2679
  }
2729
- return { fresh, full, question: rest };
2680
+ if (token === "--fresh") {
2681
+ return { question: "", invalid: "--fresh was removed. Every new thread starts with main-session context; type /clear inside /bro btw to start over." };
2682
+ }
2683
+ return { question: "", invalid: `Unknown /bro btw flag: ${token}` };
2730
2684
  }
2731
2685
 
2732
- export function resolveBtwThread(existing: BtwThread | undefined, parsed: { fresh: boolean; full?: boolean }): BtwThread {
2733
- const targetFull = parsed.full ?? existing?.full ?? false;
2734
- const startFresh = parsed.fresh || (parsed.full !== undefined && existing !== undefined && existing.full !== parsed.full);
2735
- return !existing || startFresh ? { turns: [], full: targetFull } : existing;
2686
+ // A new thread starts conversation-only; reopening keeps the thread and its mode.
2687
+ export function resolveBtwThread(existing: BtwThread | undefined): BtwThread {
2688
+ return existing ?? { turns: [], full: false };
2736
2689
  }
2737
2690
 
2738
2691
  export function bindBtwBackend(thread: BtwThread, backend: BackendName): boolean {
2739
2692
  const changed = thread.backend !== undefined && thread.backend !== backend;
2740
- if (changed) { thread.turns = []; thread.conversationId = undefined; }
2693
+ if (changed) { thread.turns = []; thread.conversationId = undefined; thread.context = undefined; thread.sessionFull = undefined; }
2741
2694
  thread.backend = backend;
2742
2695
  return changed;
2743
2696
  }
2744
2697
 
2698
+ export function btwModeLabel(full: boolean): string {
2699
+ return full ? "full permission" : "conversation-only";
2700
+ }
2701
+
2702
+ // /mode keeps the transcript; whether the native session survives is decided by
2703
+ // nativeBtwContinuation on the next turn.
2704
+ export function toggleBtwMode(thread: BtwThread): void {
2705
+ thread.full = !thread.full;
2706
+ }
2707
+
2708
+ // Claude and Grok run btw in the workspace for both modes and resume one native session across
2709
+ // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
2710
+ // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
2711
+ // with the main-session context and the whole thread.
2712
+ export function nativeBtwContinuation(thread: BtwThread, backend: BackendName): string | undefined {
2713
+ if (backend === "agy" && thread.conversationId && thread.sessionFull !== undefined && thread.sessionFull !== thread.full) {
2714
+ thread.conversationId = undefined;
2715
+ }
2716
+ return thread.conversationId;
2717
+ }
2718
+
2745
2719
  export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
2746
2720
  return turns
2747
2721
  .map((turn) => {
@@ -2756,26 +2730,23 @@ export function formatBtwTranscript(turns: readonly BtwTurn[]): string {
2756
2730
 
2757
2731
  export type BtwComposerAction =
2758
2732
  | { kind: "clear" }
2733
+ | { kind: "mode" }
2759
2734
  | { kind: "retry" }
2760
2735
  | { kind: "clipboard"; all: boolean }
2761
- | { kind: "insert"; all: boolean; force: boolean }
2736
+ | { kind: "insert"; all: boolean }
2737
+ | { kind: "removed"; command: string }
2762
2738
  | { kind: "question"; text: string };
2763
2739
 
2764
2740
  export function parseBtwComposerCommand(value: string): BtwComposerAction {
2765
2741
  const command = value.trim();
2766
2742
  if (command === "/clear") return { kind: "clear" };
2743
+ if (command === "/mode") return { kind: "mode" };
2767
2744
  if (command === "/retry" || command === "") return { kind: "retry" };
2768
2745
  if (command === "/copy" || command === "/copy-all") {
2769
2746
  return { kind: "clipboard", all: command === "/copy-all" };
2770
2747
  }
2771
- if (
2772
- command === "/insert" ||
2773
- command === "/insert!" ||
2774
- command === "/insert-all" ||
2775
- command === "/insert-all!"
2776
- ) {
2777
- return { kind: "insert", all: command.startsWith("/insert-all"), force: command.endsWith("!") };
2778
- }
2748
+ if (command === "/insert" || command === "/insert-all") return { kind: "insert", all: command === "/insert-all" };
2749
+ if (command === "/insert!" || command === "/insert-all!") return { kind: "removed", command };
2779
2750
  return { kind: "question", text: command };
2780
2751
  }
2781
2752
 
@@ -2791,12 +2762,6 @@ async function runBtwTurn(
2791
2762
  signal: AbortSignal,
2792
2763
  onProgress?: (text: string) => void,
2793
2764
  ): Promise<{ text: string; conversationId?: string }> {
2794
- // Claude continuation is not wired yet; Agy and Grok use native sessions.
2795
- // Reject Claude explicitly instead of falling back to another backend.
2796
- if (selection.backend === "claude") {
2797
- const name = "Claude";
2798
- throw new Error(`\`btw\` is not supported on the ${name} backend. Run \`/bro config\` to give it an Agy model.`);
2799
- }
2800
2765
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
2801
2766
  let latest: string | undefined;
2802
2767
  const throttledProgress = onProgress
@@ -2950,14 +2915,14 @@ class BtwModal implements Focusable {
2950
2915
  const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
2951
2916
 
2952
2917
  const model = this.model ? this.theme.fg("dim", ` · ${this.model}`) : "";
2953
- const mode = this.full ? this.theme.fg("dim", " · ") + this.theme.fg("accent", this.theme.bold("full · edits repo")) : "";
2918
+ const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
2954
2919
  const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
2955
2920
 
2956
2921
  const composer = this.input.render(innerWidth)[0] ?? "";
2957
2922
 
2958
2923
  const controls = this.running
2959
2924
  ? this.theme.fg("dim", "Thinking… · Esc cancel")
2960
- : this.theme.fg("dim", "Enter ask · Esc close · /copy · /copy-all · /insert · /insert-all · /clear · /retry");
2925
+ : this.theme.fg("dim", "Enter ask · Esc close · /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry");
2961
2926
 
2962
2927
  const lines = [
2963
2928
  this.borderLine(innerWidth, "top"),
@@ -2983,7 +2948,7 @@ class BtwModal implements Focusable {
2983
2948
 
2984
2949
  async function openBtwModal(
2985
2950
  ctx: ExtensionCommandContext,
2986
- options: { thread: BtwThread; initialQuestion?: string; seed: boolean },
2951
+ options: { thread: BtwThread; initialQuestion?: string },
2987
2952
  ): Promise<void> {
2988
2953
  const thread = options.thread;
2989
2954
 
@@ -3021,15 +2986,20 @@ async function openBtwModal(
3021
2986
  } catch (error) {
3022
2987
  controller = undefined; modal.setRunning(false); modal.setNotice(errorMessage(error)); return;
3023
2988
  }
3024
- const first = thread.turns.length === 0;
3025
- let context: string | undefined;
3026
- if (first && options.seed) {
3027
- const captured = captureShowTranscript(ctx, BTW_CONTEXT_TURNS);
3028
- context = captured?.text;
2989
+ if (thread.turns.length === 0) {
2990
+ thread.conversationId = undefined;
2991
+ let context = captureShowTranscript(ctx, BTW_CONTEXT_TURNS)?.text;
3029
2992
  if (context && context.length > BTW_CONTEXT_MAX) {
3030
2993
  context = `${context.slice(0, BTW_CONTEXT_MAX)}\n[… context truncated …]`;
3031
2994
  }
2995
+ thread.context = context;
3032
2996
  }
2997
+ // Resume natively when possible; otherwise seed the fresh native session with the main-session
2998
+ // context plus every earlier turn so nothing is silently lost.
2999
+ const conversationId = nativeBtwContinuation(thread, capabilityBackend(settings, "btw"));
3000
+ const history = !conversationId && thread.turns.length ? formatBtwTranscript(thread.turns) : undefined;
3001
+ const context = conversationId ? undefined : thread.context;
3002
+ const full = thread.full;
3033
3003
 
3034
3004
  thread.turns.push({ question, answer: "…" });
3035
3005
  modal.setText(transcript());
@@ -3039,9 +3009,9 @@ async function openBtwModal(
3039
3009
  thread.model = selectionLabel(selection);
3040
3010
  modal.setModel(thread.model);
3041
3011
  const result = await runBtwTurn(
3042
- buildBtwPrompt(context, question),
3012
+ buildBtwPrompt(context, question, { full, history }),
3043
3013
  selection,
3044
- { full: thread.full, cwd: ctx.cwd, conversationId: thread.conversationId },
3014
+ { full, cwd: ctx.cwd, conversationId },
3045
3015
  turnController.signal,
3046
3016
  (partial) => {
3047
3017
  if (closed || turnController.signal.aborted) return;
@@ -3050,7 +3020,10 @@ async function openBtwModal(
3050
3020
  },
3051
3021
  );
3052
3022
  if (turnController.signal.aborted) return;
3053
- if (result.conversationId) thread.conversationId = result.conversationId;
3023
+ if (result.conversationId) {
3024
+ thread.conversationId = result.conversationId;
3025
+ thread.sessionFull = full;
3026
+ }
3054
3027
  thread.turns[thread.turns.length - 1]!.answer = result.text;
3055
3028
  } catch (error) {
3056
3029
  if (turnController.signal.aborted || closed) return;
@@ -3065,9 +3038,16 @@ async function openBtwModal(
3065
3038
  }
3066
3039
  };
3067
3040
 
3041
+ const toggleMode = () => {
3042
+ toggleBtwMode(thread);
3043
+ modal.setFull(thread.full);
3044
+ modal.setNotice(`Mode: ${btwModeLabel(thread.full)}`);
3045
+ };
3046
+
3068
3047
  const clear = () => {
3069
3048
  thread.turns = [];
3070
3049
  thread.conversationId = undefined;
3050
+ thread.context = undefined;
3071
3051
  modal.clearComposer();
3072
3052
  modal.setNotice("");
3073
3053
  modal.setText("");
@@ -3098,14 +3078,14 @@ async function openBtwModal(
3098
3078
  }
3099
3079
  };
3100
3080
 
3101
- const insert = (all: boolean, force: boolean) => {
3081
+ const insert = (all: boolean) => {
3102
3082
  const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
3103
3083
  if (!text.trim()) {
3104
3084
  modal.setNotice("Nothing to insert yet.");
3105
3085
  return;
3106
3086
  }
3107
- if (ctx.ui.getEditorText().trim() && !force) {
3108
- modal.setNotice("Main editor has a draft. Use /insert! (or /insert-all!) to replace it.");
3087
+ if (ctx.ui.getEditorText().trim()) {
3088
+ modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
3109
3089
  return;
3110
3090
  }
3111
3091
  ctx.ui.setEditorText(text);
@@ -3119,6 +3099,11 @@ async function openBtwModal(
3119
3099
  clear();
3120
3100
  return;
3121
3101
  }
3102
+ if (action.kind === "mode") {
3103
+ modal.clearComposer();
3104
+ toggleMode();
3105
+ return;
3106
+ }
3122
3107
  if (action.kind === "clipboard") {
3123
3108
  modal.clearComposer();
3124
3109
  void copyOut(action.all);
@@ -3126,7 +3111,12 @@ async function openBtwModal(
3126
3111
  }
3127
3112
  if (action.kind === "insert") {
3128
3113
  modal.clearComposer();
3129
- insert(action.all, action.force);
3114
+ insert(action.all);
3115
+ return;
3116
+ }
3117
+ if (action.kind === "removed") {
3118
+ modal.clearComposer();
3119
+ modal.setNotice(`${action.command} was removed. Edit or clear the main editor draft, then use /insert or /insert-all.`);
3130
3120
  return;
3131
3121
  }
3132
3122
  if (action.kind === "retry") {
@@ -3288,6 +3278,12 @@ export default async function bro(pi: ExtensionAPI) {
3288
3278
  let action = parts[0] ?? "";
3289
3279
  let value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
3290
3280
 
3281
+ // Removed commands must never fall through to a paid text explanation.
3282
+ if (action === "model" || action === "effort") {
3283
+ ctx.ui.notify(`/bro ${action} was removed. Use /bro config to choose the shared default and per-capability ${action}; use /bro text <text> to explain text.`, "warning");
3284
+ return;
3285
+ }
3286
+
3291
3287
  // An unknown first word means the whole input is the source: route it by shape.
3292
3288
  if (action && !KNOWN_ACTIONS.has(action)) {
3293
3289
  const candidate = unquote(raw);
@@ -3422,253 +3418,6 @@ export default async function bro(pi: ExtensionAPI) {
3422
3418
  return;
3423
3419
  }
3424
3420
 
3425
- if (action === "model") {
3426
- if (parts.length > 2) {
3427
- ctx.ui.notify("Use /bro model or /bro model <id>.", "warning");
3428
- return;
3429
- }
3430
- try {
3431
- const settings = await readSettings();
3432
- // /bro model operates on the effective shared backend: no Agy catalog
3433
- // when the shared default is Claude/Grok.
3434
- if ((settings.backend ?? "agy") === "grok") {
3435
- const requested = value.trim();
3436
- let model: string | undefined;
3437
- if (requested) {
3438
- try {
3439
- model = resolveGrokModel(requested);
3440
- } catch {
3441
- ctx.ui.notify("Use /bro model or /bro model <grok-4.7|grok-4.7-build-fast|model-id>.", "warning");
3442
- return;
3443
- }
3444
- } else {
3445
- if (ctx.mode !== "tui") {
3446
- ctx.ui.notify("Use /bro model <grok-4.7|grok-4.7-build-fast|model-id> outside Pi's interactive UI.", "warning");
3447
- return;
3448
- }
3449
- const choices = [
3450
- ...GROK_MODELS.map(
3451
- (entry) => `${entry.id} — ${entry.label}${entry.id === settings.model ? " (current)" : ""}`,
3452
- ),
3453
- "Custom — enter a model ID…",
3454
- ];
3455
- const choice = await ctx.ui.select(`Grok model (current: ${settings.model})`, choices);
3456
- if (!choice) return;
3457
- if (choice.startsWith("Custom")) {
3458
- const input = await ctx.ui.input("Grok model", "grok-4.7, grok-4.7-build-fast, or an explicit model ID");
3459
- if (!input?.trim()) return;
3460
- model = resolveGrokModel(input);
3461
- } else {
3462
- model = GROK_MODELS[choices.indexOf(choice)]?.id;
3463
- }
3464
- }
3465
- if (!model) return;
3466
- const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
3467
- await writeSettings({ ...settings, backend: "grok", model, effort });
3468
- ctx.ui.notify(`Bro model: grok ${model}${effort === "default" ? "" : ` (${effort})`}`, "info");
3469
- return;
3470
- }
3471
- if ((settings.backend ?? "agy") === "claude") {
3472
- const requested = value.trim();
3473
- let model: string | undefined;
3474
- if (requested) {
3475
- try {
3476
- model = resolveClaudeModel(requested);
3477
- } catch {
3478
- ctx.ui.notify("Use /bro model or /bro model <sonnet|opus|model-id>.", "warning");
3479
- return;
3480
- }
3481
- } else {
3482
- if (ctx.mode !== "tui") {
3483
- ctx.ui.notify("Use /bro model <sonnet|opus|model-id> outside Pi's interactive UI.", "warning");
3484
- return;
3485
- }
3486
- const choices = [
3487
- ...CLAUDE_MODELS.map(
3488
- (entry) => `${entry.id} — ${entry.label}${entry.id === settings.model ? " (current)" : ""}`,
3489
- ),
3490
- "Custom — enter a model ID…",
3491
- ];
3492
- const choice = await ctx.ui.select(`Claude model (current: ${settings.model})`, choices);
3493
- if (!choice) return;
3494
- if (choice.startsWith("Custom")) {
3495
- const input = await ctx.ui.input("Claude model", "sonnet, opus, or an explicit model ID");
3496
- if (!input?.trim()) return;
3497
- model = resolveClaudeModel(input);
3498
- } else {
3499
- model = CLAUDE_MODELS[choices.indexOf(choice)]?.id;
3500
- }
3501
- }
3502
- if (!model) return;
3503
- const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
3504
- await writeSettings({ ...settings, backend: "claude", model, effort });
3505
- ctx.ui.notify(`Bro model: claude ${model}${effort === "default" ? "" : ` (${effort})`}`, "info");
3506
- return;
3507
- }
3508
- const models = await listAgyModels(pi);
3509
- const current = resolveCatalogSettings(settings, models);
3510
- const requested = parts[1];
3511
- let selected: AgyModelFamily | undefined;
3512
- let selectedEffort: BroEffort | undefined;
3513
- if (requested) {
3514
- selected = models.find((item) => item.id.toLowerCase() === requested);
3515
- if (!selected) {
3516
- for (const family of models) {
3517
- const variant = family.variants.find((item) => item.id.toLowerCase() === requested);
3518
- if (variant) {
3519
- selected = family;
3520
- selectedEffort = variant.effort ?? "default";
3521
- break;
3522
- }
3523
- }
3524
- }
3525
- if (!selected) {
3526
- ctx.ui.notify(`Unknown Agy model "${requested}". Run /bro model to see available choices.`, "warning");
3527
- return;
3528
- }
3529
- } else {
3530
- if (ctx.mode !== "tui") {
3531
- ctx.ui.notify("Use /bro model <id> outside Pi's interactive UI.", "warning");
3532
- return;
3533
- }
3534
- const ordered = [...models].sort((a, b) => Number(b.id === current.family?.id) - Number(a.id === current.family?.id));
3535
- const choices = ordered.map(
3536
- (item) =>
3537
- `${item.id} — ${item.label} · ${item.efforts.length ? item.efforts.join("/") : "fixed effort"}${item.id === current.family?.id ? " (current)" : ""}`,
3538
- );
3539
- const choice = await ctx.ui.select(`Agy model (current: ${current.settings.model})`, choices);
3540
- if (!choice) return;
3541
- selected = ordered[choices.indexOf(choice)];
3542
- }
3543
- if (!selectedEffort) {
3544
- const currentEffort = current.settings.effort;
3545
- const canKeepCurrent =
3546
- current.family?.id === selected.id &&
3547
- (currentEffort === "default" ? !selected.efforts.length : selected.efforts.includes(currentEffort as AgyEffort));
3548
- selectedEffort = canKeepCurrent ? currentEffort : preferredEffort(selected);
3549
- }
3550
- await writeSettings({ ...settings, model: selected.id, effort: selectedEffort });
3551
- ctx.ui.notify(
3552
- `Bro model: ${selected.id}${selectedEffort === "default" ? "" : ` (${selectedEffort})`}`,
3553
- "info",
3554
- );
3555
- } catch (error) {
3556
- ctx.ui.notify(withDoctor(error), "error");
3557
- }
3558
- return;
3559
- }
3560
-
3561
- if (action === "effort") {
3562
- const requested = parts[1];
3563
- // Claude/Grok levels pass this gate; each backend branch below validates strictly
3564
- // (the Agy branch still rejects xhigh/max with its supports-list warning).
3565
- if (parts.length > 2 || (requested && !isClaudeEffort(requested) && !EFFORTS.some((effort) => effort === requested))) {
3566
- ctx.ui.notify("Use /bro effort, or choose low, medium, or high.", "warning");
3567
- return;
3568
- }
3569
- try {
3570
- const settings = await readSettings();
3571
- // /bro effort operates on the effective shared backend: no Agy catalog
3572
- // when the shared default is Claude/Grok.
3573
- if ((settings.backend ?? "agy") === "grok") {
3574
- const allowed = ["default", ...GROK_EFFORTS] as const;
3575
- if (requested && !allowed.some((effort) => effort === requested)) {
3576
- ctx.ui.notify("Use /bro effort, or choose default, low, medium, high, or xhigh.", "warning");
3577
- return;
3578
- }
3579
- let selected = requested as BroEffort | undefined;
3580
- if (!selected) {
3581
- if (ctx.mode !== "tui") {
3582
- ctx.ui.notify("Use /bro effort <default|low|medium|high|xhigh> outside Pi's interactive UI.", "warning");
3583
- return;
3584
- }
3585
- const efforts = [...allowed].sort(
3586
- (a, b) => Number(b === settings.effort) - Number(a === settings.effort),
3587
- );
3588
- const choices = efforts.map((effort) => `${effort}${effort === settings.effort ? " (current)" : ""}`);
3589
- const choice = await ctx.ui.select(`Grok reasoning effort (current: ${settings.effort})`, choices);
3590
- if (!choice) return;
3591
- selected = efforts[choices.indexOf(choice)];
3592
- }
3593
- if (!selected || !isGrokEffort(selected)) return;
3594
- await writeSettings({ ...settings, backend: "grok", model: settings.model, effort: selected });
3595
- ctx.ui.notify(
3596
- selected === "default" ? "Bro reasoning effort: built into the selected model" : `Bro reasoning effort: ${selected}`,
3597
- "info",
3598
- );
3599
- return;
3600
- }
3601
- if ((settings.backend ?? "agy") === "claude") {
3602
- const allowed = ["default", ...CLAUDE_EFFORTS] as const;
3603
- if (requested && !allowed.some((effort) => effort === requested)) {
3604
- ctx.ui.notify("Use /bro effort, or choose default, low, medium, high, xhigh, or max.", "warning");
3605
- return;
3606
- }
3607
- let selected = requested as BroEffort | undefined;
3608
- if (!selected) {
3609
- if (ctx.mode !== "tui") {
3610
- ctx.ui.notify("Use /bro effort <default|low|medium|high|xhigh|max> outside Pi's interactive UI.", "warning");
3611
- return;
3612
- }
3613
- const efforts = [...CLAUDE_EFFORTS].sort(
3614
- (a, b) => Number(b === settings.effort) - Number(a === settings.effort),
3615
- );
3616
- const choices = efforts.map((effort) => `${effort}${effort === settings.effort ? " (current)" : ""}`);
3617
- const choice = await ctx.ui.select(`Claude reasoning effort (current: ${settings.effort})`, choices);
3618
- if (!choice) return;
3619
- selected = efforts[choices.indexOf(choice)];
3620
- }
3621
- if (!selected || !isClaudeEffort(selected)) return;
3622
- await writeSettings({ ...settings, backend: "claude", model: settings.model, effort: selected });
3623
- ctx.ui.notify(
3624
- selected === "default" ? "Bro reasoning effort: built into the selected model" : `Bro reasoning effort: ${selected}`,
3625
- "info",
3626
- );
3627
- return;
3628
- }
3629
- const current = resolveCatalogSettings(settings, await listAgyModels(pi));
3630
- if (!current.family) {
3631
- ctx.ui.notify(`Model "${settings.model}" is not in Agy's current model list. Run /bro model first.`, "warning");
3632
- return;
3633
- }
3634
- if (!current.family.efforts.length) {
3635
- if (requested && requested !== "default") {
3636
- ctx.ui.notify(`${current.family.label} uses a fixed effort level.`, "warning");
3637
- return;
3638
- }
3639
- await writeSettings({ ...current.settings, model: current.family.id, effort: "default" });
3640
- ctx.ui.notify(`${current.family.label} uses its built-in effort level.`, "info");
3641
- return;
3642
- }
3643
- if (requested === "default" || (requested && !current.family.efforts.includes(requested as AgyEffort))) {
3644
- ctx.ui.notify(
3645
- `${current.family.label} supports ${current.family.efforts.join(" or ")} effort.`,
3646
- "warning",
3647
- );
3648
- return;
3649
- }
3650
- let selected = requested as AgyEffort | undefined;
3651
- if (!selected) {
3652
- if (ctx.mode !== "tui") {
3653
- ctx.ui.notify("Use /bro effort <low|medium|high> outside Pi's interactive UI.", "warning");
3654
- return;
3655
- }
3656
- const efforts = [...current.family.efforts].sort(
3657
- (a, b) => Number(b === current.settings.effort) - Number(a === current.settings.effort),
3658
- );
3659
- const choices = efforts.map((effort) => `${effort}${effort === current.settings.effort ? " (current)" : ""}`);
3660
- const choice = await ctx.ui.select(`Agy reasoning effort (current: ${current.settings.effort})`, choices);
3661
- if (!choice) return;
3662
- selected = efforts[choices.indexOf(choice)];
3663
- }
3664
- await writeSettings({ ...current.settings, model: current.family.id, effort: selected });
3665
- ctx.ui.notify(`Bro reasoning effort: ${selected}`, "info");
3666
- } catch (error) {
3667
- ctx.ui.notify(withDoctor(error), "error");
3668
- }
3669
- return;
3670
- }
3671
-
3672
3421
  if (action === "btw") {
3673
3422
  if (ctx.mode !== "tui") {
3674
3423
  ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
@@ -3681,11 +3430,10 @@ export default async function bro(pi: ExtensionAPI) {
3681
3430
  }
3682
3431
  try {
3683
3432
  const btwBackend = capabilityBackend(await readSettings(), "btw");
3684
- if (btwBackend === "claude") throw new Error(`${btwBackend === "claude" ? "Claude" : "Grok"} does not support /bro btw yet; choose an Agy or Grok override in /bro config.`);
3685
- const thread = resolveBtwThread(btwThread, parsed);
3433
+ const thread = resolveBtwThread(btwThread);
3686
3434
  if (bindBtwBackend(thread, btwBackend)) ctx.ui.notify("Backend changed — started a fresh side thread.", "info");
3687
3435
  btwThread = thread;
3688
- await openBtwModal(ctx, { thread, initialQuestion: parsed.question, seed: !parsed.fresh });
3436
+ await openBtwModal(ctx, { thread, initialQuestion: parsed.question });
3689
3437
  } catch (error) {
3690
3438
  ctx.ui.notify(withDoctor(error), "error");
3691
3439
  }