pi-bro 0.19.5 → 0.21.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,34 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.21.0] - 2026-10-04
6
+
7
+ ### Added
8
+
9
+ - `bro-preferences.md`: tell Bro about yourself and how you like answers. Bro adds it, JSON-quoted and labelled, to every explain, `/bro show`, and `/bro btw` prompt alongside its own instructions; the advisor never receives it. Preferences can change wording, tone, technical depth, and the answer language (code, commands, paths, names, and numbers stay verbatim). In Show they change only wording and language. Per-run choices (the mode, **M**, a Show steering query, a BTW question) win over them, and Bro's source rules, Show's hard rules, and BTW access mode always apply. Blank or missing preferences leave every prompt byte-identical to 0.20.0 (#94).
10
+ - `/bro preferences` edits the file (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close). Without a file it opens with unsaved starter text that restates the old built-in audience and brief wording. Saves are checked against the 4,000-character limit, and an oversize file still opens so it can be trimmed (#94).
11
+ - The modal header shows `· prefs` when preferences shaped an explanation, drawing, or BTW turn; `/bro open` keeps the result's tag (#94).
12
+
13
+ ### Changed
14
+
15
+ - Modes, **M**, and the source guard now always apply; nothing disables them (#94).
16
+ - Preferences over 4,000 characters stop explain, Show, and BTW with an actionable error before any backend call instead of being truncated. `/bro doctor` replaces its Prompt check with a Preferences check, and `/bro help` shows the preferences status (#94).
17
+ - When preferences change mid-thread, the next BTW turn starts a fresh native session reseeded with the quoted thread, on every backend, so old preferences don't linger in the backend's history (#94).
18
+
19
+ ### Removed
20
+
21
+ - **Breaking:** `bro-prompt.md` is no longer read. It was a full replacement template for the explain prompt that disabled modes, **M**, and the source guard and did not affect Show or BTW. Bro does not migrate, rename, or delete it. Move what you want to keep into `/bro preferences`, and use `/bro mode brief` for the original ELI-simpleton instruction (#94).
22
+
23
+ ## [0.20.0] - 2026-10-04
24
+
25
+ ### Added
26
+
27
+ - Press **M** in an explanation modal to re-simplify the captured source in the next explain mode (brief → balanced → faithful). The switch applies to that explanation only: the saved mode is unchanged, **R** and `/bro open` keep the shown mode, and a second press while Bro is working cancels and skips ahead. The header now names the mode; **M** is hidden for Show, Doctor, and custom prompts.
28
+
29
+ ### Changed
30
+
31
+ - `/bro btw` now tells the model who it is writing for: a tired reader who needs the point first, in plain words, at a length that fits the question, in the language they asked in. The guidance describes the reader and the goal rather than a fixed template. In full-permission mode, answers say which points were checked in the workspace.
32
+
5
33
  ## [0.19.5] - 2026-10-01
6
34
 
7
35
  ### Changed
package/README.md CHANGED
@@ -56,7 +56,8 @@ installing it, use `pi -e npm:pi-bro`.
56
56
  | Recent session turns | `/bro show` | Draws the last turns' conversation text as shapes instead of prose (tool calls, tool results, reasoning, and images are omitted). |
57
57
  | Any of the above, auto-detected | `/bro <input>` | Routes a lone URL to the webpage reader, an existing workspace file with a supported extension to the document reader, and anything else to pasted text. |
58
58
 
59
- Pressing **R** simplifies the captured source again. These commands capture a
59
+ Pressing **R** simplifies the captured source again in the mode shown, and
60
+ **M** re-simplifies it in the next mode. These commands capture a
60
61
  new source: `/bro text`, `/bro file`, `/bro url`, and `/bro show`. Giving `/bro` a URL, path, or
61
62
  text directly captures a new source the same way.
62
63
 
@@ -71,8 +72,9 @@ text directly captures a new source the same way.
71
72
  | `/bro url <url>` | Explain one public, text-based webpage. |
72
73
  | `/bro open` | Reopen the latest explanation without calling the simplifier again. |
73
74
  | `/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. |
74
- | `/bro doctor` | Check Bro's settings, prompt, and each selected backend, with the effective backend/model/effort per feature. |
75
+ | `/bro doctor` | Check Bro's settings, preferences, and each selected backend, with the effective backend/model/effort per feature. |
75
76
  | `/bro mode [brief\|balanced\|faithful]` | View or choose the explanation mode. |
77
+ | `/bro preferences` | View or edit what Bro knows about you and how you like answers; see [Preferences](#preferences). |
76
78
  | `/bro config` | Open an interactive settings screen for the shared default backend/model/effort, explain mode, show turns, and per-capability (explain/show/btw/advisor) overrides. |
77
79
  | `/bro btw [question]` | Open a side conversation in a modal, seeded with recent main-session context. Starts conversation-only; type `/mode` inside to toggle full permission (read and edit the workspace) without losing the thread. |
78
80
  | `/bro advisor` | Quick notice of whether the executor's `bro_advisor` tool is available right now, pointing at `/bro config`, `/bro advisor-steer`, and `/bro doctor`. |
@@ -90,8 +92,10 @@ Giving `/bro` the input directly works the same way:
90
92
  ## Explanation modes
91
93
 
92
94
  Bro treats the source as data, rejects embedded instructions, preserves its
93
- language, and avoids adding facts, advice, or conclusions in every mode. Choose
94
- a persistent mode with `/bro mode`:
95
+ language (unless your [preferences](#preferences) name another), and avoids
96
+ adding facts, advice, or conclusions in every mode. Choose
97
+ a persistent mode with `/bro mode`, or press **M** in an explanation to try the
98
+ next mode without saving it:
95
99
 
96
100
  - **`brief`**: Uses the original audience-led ELI-simpleton prompt with no fixed
97
101
  word target.
@@ -105,13 +109,19 @@ a persistent mode with `/bro mode`:
105
109
  - **Mouse wheel / trackpad**: Scroll in regular or fullscreen mode
106
110
  - **↑ / ↓**: Scroll in any mode
107
111
  - **C**: Copy the complete explanation
108
- - **R**: Simplify the captured source or run the current Doctor check again
112
+ - **R**: Simplify the captured source again in the mode shown, or run the
113
+ current Doctor check again
114
+ - **M**: Re-simplify the captured source in the next mode (brief → balanced →
115
+ faithful → brief). It applies to this explanation only and does not change
116
+ the mode saved by `/bro mode`; pressing it again while Bro is working skips
117
+ ahead
109
118
  - **O**: Open the HTML diagram when a show reply contains one
110
119
  - **Esc**: Close the modal, or cancel while Bro is working
111
120
 
112
121
  The modal header shows the model and reasoning effort the explanation or
113
- drawing used (`default` when the model's own effort applies); `/bro open`
114
- keeps the original label.
122
+ drawing used (`default` when the model's own effort applies), followed by the
123
+ explanation mode, and `prefs` when your [preferences](#preferences) shaped the
124
+ result; `/bro open` keeps the original labels, mode, and tag.
115
125
 
116
126
  Bro temporarily captures mouse input while its modal is open. Native mouse
117
127
  selection may be unavailable or visually extend outside the modal depending on
@@ -123,7 +133,9 @@ your terminal mode; press **C** to copy the complete explanation reliably.
123
133
  `/bro btw` opens a separate multi-turn conversation in a modal, so you can ask
124
134
  a quick side question while the main agent keeps working. It runs through the
125
135
  selected backend and never adds anything to Pi's conversation unless you
126
- explicitly insert it into the editor.
136
+ explicitly insert it into the editor. Bro writes for a tired reader: the point
137
+ first, in plain words, at a length that fits the question, in the language you
138
+ asked in.
127
139
 
128
140
  - `/bro btw <question>` asks immediately; `/bro btw` opens an empty thread.
129
141
  Reopening keeps the thread and its mode.
@@ -232,7 +244,9 @@ assistant conversation text, including every intermediate assistant message in
232
244
  a turn — tool calls, tool results, reasoning, and images never leave the
233
245
  session. It runs the same backend-specific model call and shows the result
234
246
  in the same modal, never touching your conversation. `/bro show` uses its own
235
- draw prompt; the explanation modes and `bro-prompt.md` do not affect it.
247
+ draw prompt; the explanation modes do not affect it. Your
248
+ [preferences](#preferences) can change only its wording and language, never
249
+ which shapes it draws or its rules.
236
250
 
237
251
  Because the draw model only ever sees conversation text, its shapes reflect
238
252
  what was *reported* in the conversation — what the assistant said it did or
@@ -684,7 +698,7 @@ it as a PDF, then use `/bro file <path>`.
684
698
  ## Check your setup
685
699
 
686
700
  Run `/bro doctor` when Bro is newly installed or something is not working. It
687
- checks Bro's settings and prompt, then probes only the backends some feature
701
+ checks Bro's settings and preferences, then probes only the backends some feature
688
702
  actually selects, and reports the effective backend/model/effort for each
689
703
  feature. Failed checks explain what to fix.
690
704
 
@@ -707,8 +721,9 @@ and any per-capability (explain/show/btw/advisor) overrides, the explanation
707
721
  mode, and the default show turn count. Changes save immediately. Esc inside a
708
722
  picker cancels that pick; Esc on the settings screen closes it, keeping
709
723
  whatever was already saved. A failed save (for example, a read-only file) is
710
- shown inline. `/bro mode` changes the mode directly, and `/bro help` shows the
711
- active settings and file path.
724
+ shown inline. `/bro mode` changes the mode directly (**M** in an explanation
725
+ tries another mode without saving it), and `/bro help` shows the active settings
726
+ and file path.
712
727
 
713
728
  Settings live in this user-editable file, created when the extension loads
714
729
  (under `$PI_CODING_AGENT_DIR` instead when that is set):
@@ -799,41 +814,66 @@ When resolving turn count for `/bro show`:
799
814
  1. **Command argument**: an explicit count like `/bro show 3` or `/bro show 1 query` overrides for that run.
800
815
  2. **Saved setting**: `showTurns` (defaults to 1).
801
816
 
802
- When resolving the explanation prompt (`explain` capability only):
803
- 1. **Custom prompt**: a valid `~/.pi/agent/bro-prompt.md` (or `$PI_CODING_AGENT_DIR/bro-prompt.md`) completely overrides all built-in modes.
804
- 2. **Saved mode**: `mode` in `bro-settings.json` (defaults to `balanced`).
805
- 3. `bro-prompt.md` applies only to `/bro`, `/bro text`, `/bro file`, and `/bro url`; it does not affect `/bro show`, `/bro btw`, or `bro_advisor`.
817
+ When shaping an answer, each part has one owner:
806
818
 
807
- ## Custom prompt
819
+ | Part | Decided by |
820
+ | --- | --- |
821
+ | Bro's source rules: quoted source treated as data, no added facts, Show's traceability rules, BTW access mode | Built in; nothing overrides them |
822
+ | How much of the source an explanation keeps | The mode: **M** for one explanation, otherwise the saved `mode` |
823
+ | What a Show drawing focuses on | The steering query |
824
+ | Who the answer is for: wording, tone, depth, and answer language | Your [preferences](#preferences), otherwise Bro's built-in defaults |
808
825
 
809
- Bro uses a built-in prompt by default. To use your own, create:
826
+ ## Preferences
810
827
 
811
- ```text
812
- ~/.pi/agent/bro-prompt.md
813
- ```
828
+ Tell Bro about yourself and how you like answers. Bro adds what you write to
829
+ every explain, `/bro show`, and `/bro btw` prompt as a labelled section,
830
+ alongside its own instructions. It never replaces them: modes, **M**, and the
831
+ source rules keep working. The advisor never receives your preferences; use
832
+ `/bro advisor-steer` for it.
814
833
 
815
- Your prompt must include `{{response}}` exactly once. For example:
834
+ Run `/bro preferences` to edit them: **Ctrl+S** saves, **Ctrl+K** deletes the
835
+ file, **Ctrl+C** copies, and **Esc** closes without saving. The first time,
836
+ the editor opens with this starter text, which is not saved until you press
837
+ **Ctrl+S**:
816
838
 
817
839
  ```md
818
- Explain this in plain English in no more than 200 words.
819
- Keep important warnings and next steps.
820
-
821
- Text to explain:
822
- {{response}}
840
+ ## About me
841
+ I'm an overworked white-collar worker, and so are my colleagues. By the end of a
842
+ hard day our brains are fried and we can only handle simple language, no matter
843
+ how sharp we are at our best.
844
+
845
+ ## How I like answers
846
+ - Explain it like I'm a simpleton: plain, everyday words.
847
+ - Go easy on analogies. No forced ones.
823
848
  ```
824
849
 
825
- Bro re-reads this file every time you simplify, so your edits take effect
826
- immediately without reloading Pi. Bro never creates or modifies this file.
827
- Existing valid custom prompts continue working unchanged.
850
+ You can also edit the file directly (under `$PI_CODING_AGENT_DIR` instead when
851
+ that is set):
852
+
853
+ ```text
854
+ ~/.pi/agent/bro-preferences.md
855
+ ```
828
856
 
829
- A valid custom prompt fully overrides all built-in mode instructions.
830
- `/bro show` is separate: it always uses its own built-in draw prompt and
831
- ignores `bro-prompt.md`. `/bro
832
- mode` still changes the saved mode, but that mode remains inactive while
833
- `bro-prompt.md` exists. Remove or rename `bro-prompt.md` to use the saved
834
- built-in mode again. If the custom prompt is invalid—for example, it has no
835
- `{{response}}` placeholder or has more than one—Bro blocks the explanation;
836
- run `/bro doctor` for the exact problem.
857
+ - **Free-form**: no placeholder or structure is required. Bro re-reads the
858
+ file on every request, so edits apply next time. A missing or empty file
859
+ means no preferences, and Bro's prompts are then exactly the built-in ones.
860
+ - **What preferences can change**: wording, tone, technical depth, length in
861
+ BTW, and the answer language. If you name a language, code, commands, paths,
862
+ names, and numbers still stay exactly as written. In `/bro show`,
863
+ preferences only change wording and language. A per-run choice (the mode,
864
+ **M**, a Show steering query, or what a BTW question asks for) wins over a
865
+ standing preference.
866
+ - **Limit**: 4,000 characters, because the text goes with every request. A
867
+ longer file stops explain, Show, and BTW with an error until you trim it;
868
+ Bro never cuts it silently. `/bro doctor` reports the problem, and the
869
+ editor still opens the file so you can fix it.
870
+ - **BTW threads**: when your preferences change, the next side question starts
871
+ a fresh backend session that is caught up on the thread, so old preferences
872
+ don't linger.
873
+
874
+ `bro-prompt.md`, the full prompt template from earlier versions, is no longer
875
+ read. Move what you want to keep into `/bro preferences`, and choose
876
+ `/bro mode brief` for the original ELI-simpleton instruction.
837
877
 
838
878
 
839
879
  ## Privacy and safety
@@ -846,6 +886,9 @@ run `/bro doctor` for the exact problem.
846
886
  seeded main-session conversation text (plus earlier turns when a native
847
887
  session is reseeded) to the selected backend. In full permission mode the
848
888
  side agent can additionally read and edit the workspace.
889
+ - **Preferences**: `bro-preferences.md` is sent with every explain, Show,
890
+ and BTW request to the selected backend. It is never sent to the advisor or
891
+ to Pi's main model.
849
892
  - **Advisor requests**: `bro_advisor` sends the executor agent's system
850
893
  instructions, active tool list (excluding `bro_advisor`), ordered
851
894
  conversation history including tool calls and tool results (unlike Show,
package/backend.ts CHANGED
@@ -2,7 +2,6 @@ import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { createInterface } from "node:readline";
6
5
 
7
6
  // Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
8
7
  // This implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
@@ -126,15 +125,20 @@ const DEFAULT_KILL_ESCALATION_MS = 5_000;
126
125
  export function beginAttempt(child: ChildProcess, signal: AbortSignal, deadlineMs: number, killEscalationMs: number): Attempt {
127
126
  let cause: StopCause | undefined;
128
127
  let killTimer: ReturnType<typeof setTimeout> | undefined;
128
+ let isClosed = false;
129
129
  let finishClose: (value: { code: number | null; exitSignal: NodeJS.Signals | null }) => void;
130
130
  const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
131
- finishClose = resolve;
132
- child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
131
+ finishClose = (val) => {
132
+ isClosed = true;
133
+ resolve(val);
134
+ };
135
+ child.once("close", (code, exitSignal) => finishClose({ code, exitSignal }));
133
136
  });
134
137
 
135
138
  const stop = (next: StopCause) => {
136
139
  if (cause) return; // latched: the first stop cause wins
137
140
  cause = next;
141
+ if (isClosed) return;
138
142
  killAgyGroup(child, "SIGTERM");
139
143
  killTimer = setTimeout(() => {
140
144
  killAgyGroup(child, "SIGKILL");
@@ -270,7 +274,6 @@ async function executeArgvPrint(
270
274
  let partial = "";
271
275
  let final = "";
272
276
  let conversationId = request.continuation?.id;
273
- let parseError: string | undefined;
274
277
 
275
278
  child.stderr?.setEncoding("utf8");
276
279
  child.stderr?.on("data", (chunk: string) => {
@@ -285,44 +288,31 @@ async function executeArgvPrint(
285
288
  child.stdin?.end(`${JSON.stringify({ event: "user", message: { content: request.prompt } })}\n`);
286
289
  }
287
290
 
288
- const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity });
289
- void attempt.closed.then(() => lines.close());
290
- try {
291
- for await (const line of lines) {
292
- if (!line.trim() || attempt.causeOf()) continue;
293
- try {
294
- if (isBtw) {
295
- const parsed = parseBtwAgyLine(line);
296
- if (parsed.conversationId) conversationId = parsed.conversationId;
297
- if (parsed.error) {
298
- parseError = parsed.error;
299
- attempt.stop("protocol");
300
- break;
301
- }
302
- if (parsed.delta) {
303
- partial += parsed.delta;
304
- onProgress?.({ kind: "text", text: partial });
305
- }
306
- if (parsed.result !== undefined) final = parsed.result;
307
- } else {
308
- const parsed = parseExplainLine(line);
309
- if (parsed.delta) {
310
- partial += parsed.delta;
311
- onProgress?.({ kind: "text", text: partial });
312
- }
313
- if (parsed.result !== undefined) final = parsed.result;
314
- }
315
- } catch (error) {
316
- parseError = errorMessage(error);
317
- attempt.stop("protocol");
318
- break;
291
+ const handleLine = (line: string) => {
292
+ if (!line.trim() || attempt.causeOf()) return;
293
+ if (isBtw) {
294
+ const parsed = parseBtwAgyLine(line);
295
+ if (parsed.conversationId) conversationId = parsed.conversationId;
296
+ if (parsed.error) throw new Error(parsed.error);
297
+ if (parsed.delta) {
298
+ partial += parsed.delta;
299
+ onProgress?.({ kind: "text", text: partial });
300
+ }
301
+ if (parsed.result !== undefined) final = parsed.result;
302
+ } else {
303
+ const parsed = parseExplainLine(line);
304
+ if (parsed.delta) {
305
+ partial += parsed.delta;
306
+ onProgress?.({ kind: "text", text: partial });
319
307
  }
308
+ if (parsed.result !== undefined) final = parsed.result;
320
309
  }
321
- } finally {
322
- lines.close();
323
- }
310
+ };
311
+
312
+ const finishFraming = frameStdoutLines(child, attempt, "Agy", handleLine);
324
313
 
325
314
  const { code, exitSignal } = await attempt.closed;
315
+ const parseError = finishFraming();
326
316
  attempt.dispose();
327
317
  const cause = attempt.causeOf();
328
318
 
@@ -348,8 +338,69 @@ async function executeArgvPrint(
348
338
  }
349
339
 
350
340
  // Guards only against a single runaway line with no newline (a protocol break, not a real
351
- // response size) -- agy's real NDJSON lines are far smaller than this.
352
- const ADVISOR_MAX_STDOUT_LINE_CHARS = 2_000_000;
341
+ // response size) -- real NDJSON lines across all backends are far smaller than this.
342
+ export const MAX_STDOUT_LINE_CHARS = 2_000_000;
343
+
344
+ export function frameStdoutLines(
345
+ child: ChildProcess,
346
+ attempt: Attempt,
347
+ backendName: string,
348
+ onLine: (line: string) => void,
349
+ ): () => string | undefined {
350
+ const oversize = `${backendName} emitted a stdout line over ${MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
351
+ let buffer = "";
352
+ let protocolError: string | undefined;
353
+
354
+ const fail = (message: string) => {
355
+ protocolError ??= message;
356
+ buffer = "";
357
+ attempt.stop("protocol");
358
+ };
359
+
360
+ child.stdout?.setEncoding("utf8");
361
+
362
+ child.stdout?.on("error", (error) => {
363
+ fail(`${backendName} stdout error: ${errorMessage(error)}`);
364
+ });
365
+
366
+ child.stdout?.on("data", (chunk: string) => {
367
+ if (attempt.causeOf()) return;
368
+ const parts = (buffer + chunk).split(/\r?\n/);
369
+ buffer = parts.pop() ?? "";
370
+
371
+ const pendingLen = buffer.endsWith("\r") ? buffer.length - 1 : buffer.length;
372
+ if (pendingLen > MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > MAX_STDOUT_LINE_CHARS)) {
373
+ return fail(oversize);
374
+ }
375
+
376
+ for (const line of parts) {
377
+ if (attempt.causeOf()) return;
378
+ if (!line.trim()) continue;
379
+ try {
380
+ onLine(line);
381
+ } catch (error) {
382
+ return fail(errorMessage(error));
383
+ }
384
+ }
385
+ });
386
+
387
+ return (): string | undefined => {
388
+ if (!attempt.causeOf() && buffer.trim()) {
389
+ const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
390
+ if (line.length > MAX_STDOUT_LINE_CHARS) {
391
+ fail(oversize);
392
+ } else if (line.trim()) {
393
+ try {
394
+ onLine(line);
395
+ } catch (error) {
396
+ fail(errorMessage(error));
397
+ }
398
+ }
399
+ }
400
+ buffer = "";
401
+ return protocolError;
402
+ };
403
+ }
353
404
 
354
405
  type AdvisorAgyEvent = {
355
406
  event?: string;
@@ -417,9 +468,7 @@ async function executeAdvisorStdin(
417
468
  let stderr = "";
418
469
  let final: string | undefined;
419
470
  let terminalError: string | undefined;
420
- let protocolError: string | undefined;
421
471
  let sawTerminal = false;
422
- let stdoutBuffer = "";
423
472
 
424
473
  child.stderr?.setEncoding("utf8");
425
474
  child.stderr?.on("data", (chunk: string) => {
@@ -455,37 +504,11 @@ async function executeAdvisorStdin(
455
504
  }
456
505
  };
457
506
 
458
- child.stdout?.setEncoding("utf8");
459
- child.stdout?.on("data", (chunk: string) => {
460
- stdoutBuffer += chunk;
461
- const parts = stdoutBuffer.split(/\r?\n/);
462
- stdoutBuffer = parts.pop() ?? "";
463
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
464
- protocolError ??= `Agy emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
465
- stdoutBuffer = "";
466
- attempt.stop("protocol");
467
- return;
468
- }
469
- for (const line of parts) {
470
- try {
471
- handleLine(line);
472
- } catch (error) {
473
- protocolError ??= errorMessage(error);
474
- attempt.stop("protocol");
475
- return;
476
- }
477
- }
478
- });
507
+ const finishFraming = frameStdoutLines(child, attempt, "Agy", handleLine);
479
508
 
480
509
  const { code, exitSignal } = await attempt.closed;
510
+ const protocolError = finishFraming();
481
511
  attempt.dispose();
482
- if (stdoutBuffer.trim() && !sawTerminal) {
483
- try {
484
- handleLine(stdoutBuffer);
485
- } catch (error) {
486
- protocolError ??= errorMessage(error);
487
- }
488
- }
489
512
 
490
513
  const cause = attempt.causeOf();
491
514
  if (cause === "cancelled") return { status: "cancelled", message: "Canceled." };
@@ -587,8 +610,6 @@ async function executeClaude(
587
610
  let partial = "";
588
611
  let final: string | undefined;
589
612
  let terminalError: string | undefined;
590
- let protocolError: string | undefined;
591
- let stdoutBuffer = "";
592
613
  let initSessionId: string | undefined;
593
614
  let resultSessionId: string | undefined;
594
615
 
@@ -623,6 +644,7 @@ async function executeClaude(
623
644
  }
624
645
  if (isAdvisor && topLevel && event.type === "assistant" && Array.isArray(event.message?.content)) {
625
646
  for (const block of event.message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
647
+ if (attempt.causeOf()) break;
626
648
  const label =
627
649
  block?.type === "tool_use" && typeof block.name === "string"
628
650
  ? block.name.trim()
@@ -653,37 +675,11 @@ async function executeClaude(
653
675
  }
654
676
  };
655
677
 
656
- child.stdout?.setEncoding("utf8");
657
- child.stdout?.on("data", (chunk: string) => {
658
- stdoutBuffer += chunk;
659
- const parts = stdoutBuffer.split(/\r?\n/);
660
- stdoutBuffer = parts.pop() ?? "";
661
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
662
- protocolError ??= `Claude emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
663
- stdoutBuffer = "";
664
- attempt.stop("protocol");
665
- return;
666
- }
667
- for (const line of parts) {
668
- try {
669
- handleLine(line);
670
- } catch (error) {
671
- protocolError ??= errorMessage(error);
672
- attempt.stop("protocol");
673
- return;
674
- }
675
- }
676
- });
678
+ const finishFraming = frameStdoutLines(child, attempt, "Claude", handleLine);
677
679
 
678
680
  const { code, exitSignal } = await attempt.closed;
681
+ const protocolError = finishFraming();
679
682
  attempt.dispose();
680
- if (stdoutBuffer.trim()) {
681
- try {
682
- handleLine(stdoutBuffer);
683
- } catch (error) {
684
- protocolError ??= errorMessage(error);
685
- }
686
- }
687
683
 
688
684
  const partialText = partial || undefined;
689
685
  const cause = attempt.causeOf();
@@ -815,8 +811,6 @@ async function executeGrok(
815
811
  let resultSessionId: string | undefined;
816
812
  let final: string | undefined;
817
813
  let terminalError: string | undefined;
818
- let protocolError: string | undefined;
819
- let stdoutBuffer = "";
820
814
 
821
815
  child.stderr?.setEncoding("utf8");
822
816
  child.stderr?.on("data", (chunk: string) => {
@@ -869,6 +863,7 @@ async function executeGrok(
869
863
  }
870
864
  if (isAdvisor && event.type === "assistant" && Array.isArray(message?.content)) {
871
865
  for (const block of message.content as Array<{ type?: unknown; name?: unknown; text?: unknown }>) {
866
+ if (attempt.causeOf()) break;
872
867
  const label =
873
868
  block?.type === "tool_use" && typeof block.name === "string"
874
869
  ? block.name.trim()
@@ -904,37 +899,11 @@ async function executeGrok(
904
899
  terminalError ??= `Grok failed: ${detail}`;
905
900
  };
906
901
 
907
- child.stdout?.setEncoding("utf8");
908
- child.stdout?.on("data", (chunk: string) => {
909
- stdoutBuffer += chunk;
910
- const parts = stdoutBuffer.split(/\r?\n/);
911
- stdoutBuffer = parts.pop() ?? "";
912
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
913
- protocolError ??= `Grok emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
914
- stdoutBuffer = "";
915
- attempt.stop("protocol");
916
- return;
917
- }
918
- for (const line of parts) {
919
- try {
920
- handleLine(line);
921
- } catch (error) {
922
- protocolError ??= errorMessage(error);
923
- attempt.stop("protocol");
924
- return;
925
- }
926
- }
927
- });
902
+ const finishFraming = frameStdoutLines(child, attempt, "Grok", handleLine);
928
903
 
929
904
  const { code, exitSignal } = await attempt.closed;
905
+ const protocolError = finishFraming();
930
906
  attempt.dispose();
931
- if (stdoutBuffer.trim()) {
932
- try {
933
- handleLine(stdoutBuffer);
934
- } catch (error) {
935
- protocolError ??= errorMessage(error);
936
- }
937
- }
938
907
 
939
908
  const partialText = partial ? { partialText: partial } : {};
940
909
  const cause = attempt.causeOf();
@@ -1076,8 +1045,6 @@ async function executeCodex(
1076
1045
  let final: string | undefined;
1077
1046
  let sawTerminal = false;
1078
1047
  let terminalError: string | undefined;
1079
- let protocolError: string | undefined;
1080
- let stdoutBuffer = "";
1081
1048
 
1082
1049
  child.stderr?.setEncoding("utf8");
1083
1050
  child.stderr?.on("data", (chunk: string) => {
@@ -1133,37 +1100,11 @@ async function executeCodex(
1133
1100
  }
1134
1101
  };
1135
1102
 
1136
- child.stdout?.setEncoding("utf8");
1137
- child.stdout?.on("data", (chunk: string) => {
1138
- stdoutBuffer += chunk;
1139
- const parts = stdoutBuffer.split(/\r?\n/);
1140
- stdoutBuffer = parts.pop() ?? "";
1141
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1142
- protocolError ??= `Codex emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1143
- stdoutBuffer = "";
1144
- attempt.stop("protocol");
1145
- return;
1146
- }
1147
- for (const line of parts) {
1148
- try {
1149
- handleLine(line);
1150
- } catch (error) {
1151
- protocolError ??= errorMessage(error);
1152
- attempt.stop("protocol");
1153
- return;
1154
- }
1155
- }
1156
- });
1103
+ const finishFraming = frameStdoutLines(child, attempt, "Codex", handleLine);
1157
1104
 
1158
1105
  const { code, exitSignal } = await attempt.closed;
1106
+ const protocolError = finishFraming();
1159
1107
  attempt.dispose();
1160
- if (stdoutBuffer.trim()) {
1161
- try {
1162
- handleLine(stdoutBuffer);
1163
- } catch (error) {
1164
- protocolError ??= errorMessage(error);
1165
- }
1166
- }
1167
1108
 
1168
1109
  const partialText = partial ? { partialText: partial } : {};
1169
1110
  const cause = attempt.causeOf();
@@ -1279,8 +1220,6 @@ async function executeMuse(
1279
1220
  let final: string | undefined;
1280
1221
  let sawTerminal = false;
1281
1222
  let terminalError: string | undefined;
1282
- let protocolError: string | undefined;
1283
- let stdoutBuffer = "";
1284
1223
 
1285
1224
  child.stderr?.setEncoding("utf8");
1286
1225
  child.stderr?.on("data", (chunk: string) => {
@@ -1358,37 +1297,11 @@ async function executeMuse(
1358
1297
  }
1359
1298
  };
1360
1299
 
1361
- child.stdout?.setEncoding("utf8");
1362
- child.stdout?.on("data", (chunk: string) => {
1363
- stdoutBuffer += chunk;
1364
- const parts = stdoutBuffer.split(/\r?\n/);
1365
- stdoutBuffer = parts.pop() ?? "";
1366
- if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1367
- protocolError ??= `Muse emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1368
- stdoutBuffer = "";
1369
- attempt.stop("protocol");
1370
- return;
1371
- }
1372
- for (const line of parts) {
1373
- try {
1374
- handleLine(line);
1375
- } catch (error) {
1376
- protocolError ??= errorMessage(error);
1377
- attempt.stop("protocol");
1378
- return;
1379
- }
1380
- }
1381
- });
1300
+ const finishFraming = frameStdoutLines(child, attempt, "Muse", handleLine);
1382
1301
 
1383
1302
  const { code, exitSignal } = await attempt.closed;
1303
+ const protocolError = finishFraming();
1384
1304
  attempt.dispose();
1385
- if (stdoutBuffer.trim()) {
1386
- try {
1387
- handleLine(stdoutBuffer);
1388
- } catch (error) {
1389
- protocolError ??= errorMessage(error);
1390
- }
1391
- }
1392
1305
 
1393
1306
  const partialText = partial ? { partialText: partial } : {};
1394
1307
  const cause = attempt.causeOf();