pi-bro 0.19.3 → 0.19.4

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,17 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.19.4] - 2026-10-01
6
+
7
+ ### Fixed
8
+
9
+ - Preserve advisor timeout and cancellation status through the consultation wrapper. Host deadlines and cancellation stop after one attempt without backoff; ordinary invocation failures retain the existing three-attempt policy (#72).
10
+ - Bound benchmark call and usage-preflight cleanup when descendants retain output pipes, reusing backend process-group termination and escalation. Ignore late output after a stop without adding benchmark retries (#72).
11
+
12
+ ### Documentation
13
+
14
+ - Distinguish host deadlines from CLI-reported failures and document unverified shutdown/reload cancellation, POSIX process-group cleanup, and Windows direct-child limits (#72).
15
+
5
16
  ## [0.19.3] - 2026-09-30
6
17
 
7
18
  ### Fixed
package/README.md CHANGED
@@ -64,7 +64,7 @@ text directly captures a new source the same way.
64
64
 
65
65
  | Command | Description |
66
66
  | --- | --- |
67
- | `/bro` or `/bro text` | Explain the latest completed assistant response. |
67
+ | `/bro`, `/bro simplify`, or `/bro text` | Explain the latest completed assistant response. |
68
68
  | `/bro text <text>` | Explain pasted text. |
69
69
  | `/bro <input>` | Explain it directly: a lone URL runs the webpage reader, an existing workspace file with a supported extension runs the document reader, and anything else is pasted text. A quoted path with spaces is routed too when the file exists. |
70
70
  | `/bro file <path>` | Explain a workspace-local `.md`, `.markdown`, `.txt`, `.pdf`, or `.docx` file. |
@@ -205,9 +205,12 @@ presence, and backend compatibility (for Agy, a minimum CLI version with an
205
205
  more evidence" is a normal result, not a failure), Bro retries with the
206
206
  identical snapshot, steering, and question: once after 5 seconds, once more
207
207
  after 10 seconds, then returns the backend's own diagnostic — including a
208
- context-length error, verbatim — as the failure. Each attempt is capped at
209
- 10 minutes. Cancelling the tool call aborts immediately and skips any
210
- pending retry wait.
208
+ context-length error, verbatim — as the failure. Host-imposed deadlines and
209
+ cancellation are terminal: neither starts another attempt or a backoff wait.
210
+ The advisor CLI is asked to stop at 10 minutes; Bro's host deadline is 610
211
+ seconds plus bounded process cleanup. A CLI-reported error before that host
212
+ deadline remains an invocation failure and can be retried.
213
+ Cancelling the tool call skips any pending retry wait.
211
214
  - **Progress and provenance**: while an attempt is running, Bro shows the last
212
215
  thing the advisor actually reported — a tool name or a user-facing response
213
216
  line, never hidden reasoning — and how long ago it arrived, e.g. `last
package/backend.ts CHANGED
@@ -123,7 +123,7 @@ const DEFAULT_KILL_ESCALATION_MS = 5_000;
123
123
  // protocol failure it signals the whole POSIX process group (SIGTERM, then SIGKILL after
124
124
  // `killEscalationMs` if the child or a misbehaving grandchild ignores it). Windows only ever
125
125
  // reaches the immediate child directly -- there is no process-tree guarantee there.
126
- function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
126
+ export function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
127
127
  let cause: StopCause | undefined;
128
128
  let killTimer: ReturnType<typeof setTimeout> | undefined;
129
129
  let finishClose: (value: { code: number | null; exitSignal: NodeJS.Signals | null }) => void;
@@ -286,6 +286,7 @@ async function executeArgvPrint(
286
286
  }
287
287
 
288
288
  const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
289
+ void attempt.closed.then(() => lines.close());
289
290
  try {
290
291
  for await (const line of lines) {
291
292
  if (!line.trim() || attempt.causeOf()) continue;
package/bro.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { hasBroCustomUi, broModalRows, canBroInsertIntoEditor, insertBroDesktopText } from "./ui-capabilities.ts";
2
3
  import { createHash } from "node:crypto";
3
4
  import { lookup } from "node:dns/promises";
4
5
  import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
@@ -9,7 +10,7 @@ import { tmpdir } from "node:os";
9
10
  import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
11
  import { stripVTControlCharacters } from "node:util";
11
12
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
12
- import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
13
+ import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
13
14
  import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
14
15
  import { Defuddle } from "defuddle/node";
15
16
  import { parseHTML } from "linkedom";
@@ -131,6 +132,7 @@ export function setRegularMouseReporting(tui: Pick<TuiLike, "mode" | "terminal">
131
132
  }
132
133
 
133
134
  const COMMANDS = [
135
+ { value: "simplify", label: "simplify", description: "Explain the latest completed assistant reply (same as /bro)" },
134
136
  { value: "text", label: "text", description: "Explain pasted text, or the latest reply when text is omitted" },
135
137
  { value: "file", label: "file", description: "Explain a local document" },
136
138
  { value: "url", label: "url", description: "Explain a public webpage" },
@@ -1818,6 +1820,14 @@ export type AdvisorActivityCallback = (label: string, timestamp: number) => void
1818
1820
  // "Transport" for why stdin rather than --print). This function keeps its exact existing
1819
1821
  // signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
1820
1822
  // which owns the 3-attempt retry/backoff policy the backend itself never performs.
1823
+ export class TerminalConsultError extends Error {
1824
+ readonly status: "failure" | "cancelled" | "timeout";
1825
+ constructor(status: "failure" | "cancelled" | "timeout", message: string) {
1826
+ super(message);
1827
+ this.status = status;
1828
+ }
1829
+ }
1830
+
1821
1831
  export async function runAdvisorConsultation(
1822
1832
  prompt: string,
1823
1833
  selection: BackendSelection,
@@ -1825,6 +1835,7 @@ export async function runAdvisorConsultation(
1825
1835
  signal: AbortSignal,
1826
1836
  killEscalationMs = 5_000,
1827
1837
  onActivity?: AdvisorActivityCallback,
1838
+ deadlineMs?: number,
1828
1839
  ): Promise<string> {
1829
1840
  const outcome = await executeBackend(
1830
1841
  { feature: "advisor", prompt, access: "workspace-full", cwd },
@@ -1835,10 +1846,10 @@ export async function runAdvisorConsultation(
1835
1846
  if (progress.kind === "activity") onActivity(progress.label, progress.timestamp);
1836
1847
  }
1837
1848
  : undefined,
1838
- { killEscalationMs },
1849
+ { killEscalationMs, deadlineMs },
1839
1850
  );
1840
1851
  if (outcome.status === "success") return outcome.text;
1841
- throw new Error(outcome.message);
1852
+ throw new TerminalConsultError(outcome.status, outcome.message);
1842
1853
  }
1843
1854
 
1844
1855
  function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
@@ -1992,7 +2003,8 @@ export async function runAdvisorWithRetries(
1992
2003
  } catch (error) {
1993
2004
  if (progressTimer) clearInterval(progressTimer);
1994
2005
  stopActivityThrottle();
1995
- if (signal.aborted || errorMessage(error) === "Canceled.") throw error;
2006
+ if (signal.aborted || errorMessage(error) === "Canceled." ||
2007
+ (error instanceof TerminalConsultError && error.status !== "failure")) throw error;
1996
2008
  lastError = error;
1997
2009
  if (attempt === delays.length) break;
1998
2010
  const delay = delays[attempt]!;
@@ -2502,7 +2514,7 @@ export function createConfigModal(
2502
2514
  }
2503
2515
 
2504
2516
  export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2505
- if (ctx.mode !== "tui") {
2517
+ if (!hasBroCustomUi(ctx)) {
2506
2518
  ctx.ui.notify("Use /bro config in Pi's interactive UI.", "warning");
2507
2519
  return;
2508
2520
  }
@@ -2619,7 +2631,7 @@ export function createAdvisorSteerModal(
2619
2631
  }
2620
2632
 
2621
2633
  export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
2622
- if (ctx.mode !== "tui") {
2634
+ if (!hasBroCustomUi(ctx)) {
2623
2635
  ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
2624
2636
  return;
2625
2637
  }
@@ -2655,7 +2667,7 @@ Quick reference. The README is the full user guide: https://github.com/tranhoang
2655
2667
 
2656
2668
  ## Explain and show
2657
2669
 
2658
- - \`/bro\` — explain the latest completed assistant reply
2670
+ - \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
2659
2671
  - \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
2660
2672
  - \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
2661
2673
  - \`/bro url <url>\` — explain one public webpage
@@ -2816,7 +2828,7 @@ class BroModal implements Focusable {
2816
2828
  render(width: number): string[] {
2817
2829
  const dialogWidth = Math.max(24, width);
2818
2830
  const innerWidth = Math.max(22, dialogWidth - 2);
2819
- const terminalRows = this.tui.terminal?.rows ?? process.stdout.rows ?? 30;
2831
+ const terminalRows = broModalRows(this.tui, process.stdout.rows);
2820
2832
  const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
2821
2833
  this.bodyHeight = Math.max(1, dialogHeight - 6);
2822
2834
 
@@ -2924,7 +2936,7 @@ interface BroModalOptions {
2924
2936
  }
2925
2937
 
2926
2938
  async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
2927
- if (ctx.mode !== "tui") {
2939
+ if (!hasBroCustomUi(ctx)) {
2928
2940
  if (options.run && !options.result && options.text === undefined) {
2929
2941
  const result = await options.run(new AbortController().signal);
2930
2942
  options.onResult?.(result);
@@ -3189,6 +3201,7 @@ class BtwModal implements Focusable {
3189
3201
  }
3190
3202
 
3191
3203
  setNotice(notice: string): void {
3204
+ if (this.disposed) return;
3192
3205
  this.notice = notice;
3193
3206
  this.tui.requestRender();
3194
3207
  }
@@ -3253,9 +3266,16 @@ class BtwModal implements Focusable {
3253
3266
  render(width: number): string[] {
3254
3267
  const dialogWidth = Math.max(24, width);
3255
3268
  const innerWidth = Math.max(22, dialogWidth - 2);
3256
- const terminalRows = this.tui.terminal?.rows ?? process.stdout.rows ?? 30;
3269
+ const terminalRows = broModalRows(this.tui, process.stdout.rows);
3257
3270
  const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
3258
- this.bodyHeight = Math.max(1, dialogHeight - 7);
3271
+ const footer = [
3272
+ ...wrapTextWithAnsi(this.theme.fg("accent", this.theme.bold("Your message to Bro")), innerWidth),
3273
+ this.input.render(innerWidth)[0] ?? "",
3274
+ ...wrapTextWithAnsi(this.running ? "Bro is thinking… · Esc: cancel response" : "Enter: send message · Esc: close panel", innerWidth),
3275
+ ...wrapTextWithAnsi("Commands: /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry", innerWidth),
3276
+ ...(this.notice ? wrapTextWithAnsi(this.theme.fg("accent", this.notice), innerWidth) : []),
3277
+ ];
3278
+ this.bodyHeight = Math.max(1, dialogHeight - 5 - footer.length);
3259
3279
 
3260
3280
  const rendered = this.markdown.render(innerWidth);
3261
3281
  this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
@@ -3268,12 +3288,6 @@ class BtwModal implements Focusable {
3268
3288
  const mode = this.theme.fg("dim", " · ") + (this.full ? this.theme.fg("accent", this.theme.bold(btwModeLabel(true))) : this.theme.fg("dim", btwModeLabel(false)));
3269
3289
  const header = this.theme.fg("accent", this.theme.bold("Bro · btw")) + model + mode + this.theme.fg("dim", scroll);
3270
3290
 
3271
- const composer = this.input.render(innerWidth)[0] ?? "";
3272
-
3273
- const controls = this.running
3274
- ? this.theme.fg("dim", "Thinking… · Esc cancel")
3275
- : this.theme.fg("dim", "Enter ask · Esc close · /mode · /copy · /copy-all · /insert · /insert-all · /clear · /retry");
3276
-
3277
3291
  const lines = [
3278
3292
  this.borderLine(innerWidth, "top"),
3279
3293
  this.frameLine(header, innerWidth),
@@ -3282,8 +3296,7 @@ class BtwModal implements Focusable {
3282
3296
  for (const line of visible) lines.push(this.frameLine(line, innerWidth));
3283
3297
  for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
3284
3298
  lines.push(this.ruleLine(innerWidth));
3285
- lines.push(this.frameLine(composer, innerWidth));
3286
- lines.push(this.frameLine(this.notice ? this.theme.fg("accent", this.notice) : controls, innerWidth));
3299
+ for (const line of footer) lines.push(this.frameLine(line, innerWidth));
3287
3300
  lines.push(this.borderLine(innerWidth, "bottom"));
3288
3301
  return lines;
3289
3302
  }
@@ -3425,12 +3438,17 @@ async function openBtwModal(
3425
3438
  }
3426
3439
  };
3427
3440
 
3428
- const insert = (all: boolean) => {
3441
+ const insert = async (all: boolean) => {
3429
3442
  const text = all ? transcript() : (thread.turns.at(-1)?.answer ?? "");
3430
3443
  if (!text.trim()) {
3431
3444
  modal.setNotice("Nothing to insert yet.");
3432
3445
  return;
3433
3446
  }
3447
+ if (!canBroInsertIntoEditor(ctx)) {
3448
+ const result = await insertBroDesktopText(ctx, text);
3449
+ modal.setNotice(result === "inserted" ? "Inserted into the main editor. Review it before sending." : result === "not_empty" ? "Main editor has a draft or attachment. Clear it first, then insert again." : "Could not confirm insertion. Check the main editor before trying again.");
3450
+ return;
3451
+ }
3434
3452
  if (ctx.ui.getEditorText().trim()) {
3435
3453
  modal.setNotice("Main editor has a draft. Edit or clear it first, then insert again.");
3436
3454
  return;
@@ -3629,6 +3647,11 @@ export default async function bro(pi: ExtensionAPI) {
3629
3647
  let action = parts[0] ?? "";
3630
3648
  let value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
3631
3649
 
3650
+ if (action === "simplify") {
3651
+ if (value) { ctx.ui.notify("Use /bro simplify for the latest reply, or /bro text <text> for pasted text.", "warning"); return; }
3652
+ action = "";
3653
+ }
3654
+
3632
3655
  // Removed commands must never fall through to a paid text explanation.
3633
3656
  if (action === "model" || action === "effort") {
3634
3657
  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");
@@ -3750,7 +3773,7 @@ export default async function bro(pi: ExtensionAPI) {
3750
3773
  const settings = await readSettings();
3751
3774
  let selected = parseBroMode(requested);
3752
3775
  if (!selected) {
3753
- if (ctx.mode !== "tui") {
3776
+ if (!hasBroCustomUi(ctx)) {
3754
3777
  ctx.ui.notify("Use /bro mode <brief|balanced|faithful> outside Pi's interactive UI.", "warning");
3755
3778
  return;
3756
3779
  }
@@ -3770,7 +3793,7 @@ export default async function bro(pi: ExtensionAPI) {
3770
3793
  }
3771
3794
 
3772
3795
  if (action === "btw") {
3773
- if (ctx.mode !== "tui") {
3796
+ if (!hasBroCustomUi(ctx)) {
3774
3797
  ctx.ui.notify("Use /bro btw in Pi's interactive UI.", "warning");
3775
3798
  return;
3776
3799
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.19.3",
3
+ "version": "0.19.4",
4
4
  "description": "An Earendil Pi extension that explains pasted text, assistant responses, local documents, public webpages, and recent session turns (as shapes) in a context-isolated window, opens a sandboxed side conversation with /bro btw, and provides a second-opinion advisor tool for executor agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  ],
38
38
  "files": [
39
39
  "bro.ts",
40
+ "ui-capabilities.ts",
40
41
  "backend.ts",
41
42
  "prompt.ts",
42
43
  "README.md",
@@ -53,7 +54,7 @@
53
54
  },
54
55
  "scripts": {
55
56
  "typecheck": "tsc --noEmit",
56
- "test": "npm run typecheck && node --test prompt.test.ts backend.test.ts claude.test.ts grok.test.ts codex.test.ts muse.test.ts settings.test.ts show-html.test.ts smoke-rpc.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
57
+ "test": "npm run typecheck && node --test prompt.test.ts backend.test.ts claude.test.ts grok.test.ts codex.test.ts muse.test.ts settings.test.ts show-html.test.ts smoke-rpc.test.ts ui-capabilities.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
57
58
  "benchmark:dry-run": "node benchmark/run.ts dry-run",
58
59
  "benchmark:run": "node benchmark/run.ts run",
59
60
  "benchmark:report": "node benchmark/run.ts report",
@@ -0,0 +1,35 @@
1
+ /** Local structural contract. This does not extend, patch, or impersonate the upstream Pi API. */
2
+ type UiContext = { mode: string; ui: unknown };
3
+ type DesktopUi = { getDesktopUiCapabilities?: () => unknown };
4
+
5
+ export function hasBroCustomUi(ctx: UiContext): boolean {
6
+ if (ctx.mode === "tui") return true;
7
+ if (ctx.mode !== "rpc" || !ctx.ui || typeof ctx.ui !== "object") return false;
8
+ const query = (ctx.ui as DesktopUi).getDesktopUiCapabilities;
9
+ if (typeof query !== "function") return false;
10
+ try {
11
+ const value = query.call(ctx.ui);
12
+ if (!value || typeof value !== "object") return false;
13
+ const capability = value as { version?: unknown; customTui?: unknown; viewport?: unknown };
14
+ return capability.version === 1 && capability.customTui === true && capability.viewport === true;
15
+ } catch { return false; }
16
+ }
17
+
18
+ /** Read a virtual terminal's actual viewport in desktop mode; preserve terminal behavior elsewhere. */
19
+ export function broModalRows(tui: unknown, terminalRows: number | undefined): number {
20
+ const rows = tui && typeof tui === "object" ? (tui as { terminal?: { rows?: unknown } }).terminal?.rows : undefined;
21
+ const candidate = typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? rows : terminalRows;
22
+ return typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0 ? Math.floor(candidate) : 30;
23
+ }
24
+
25
+ export async function insertBroDesktopText(ctx: UiContext, text: string): Promise<"inserted" | "not_empty" | "unavailable"> {
26
+ if (!hasBroCustomUi(ctx) || ctx.mode !== "rpc") return "unavailable";
27
+ const ui = ctx.ui as DesktopUi & { insertEditorTextIfEmpty?: (text: string) => Promise<"inserted" | "not_empty" | "unavailable"> };
28
+ try {
29
+ const caps = ui.getDesktopUiCapabilities?.() as { atomicEditorInsert?: boolean } | undefined;
30
+ return caps?.atomicEditorInsert === true && ui.insertEditorTextIfEmpty ? await ui.insertEditorTextIfEmpty(text) : "unavailable";
31
+ } catch { return "unavailable"; }
32
+ }
33
+
34
+ /** Only terminal mode uses synchronous insertion; Desktop uses insertBroDesktopText instead. */
35
+ export function canBroInsertIntoEditor(ctx: UiContext): boolean { return ctx.mode === "tui"; }