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 +28 -0
- package/README.md +81 -38
- package/backend.ts +103 -190
- package/bro.ts +308 -81
- package/package.json +2 -2
- package/prompt.ts +54 -8
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
|
|
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,
|
|
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
|
|
94
|
-
|
|
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
|
|
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)
|
|
114
|
-
|
|
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
|
|
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
|
|
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
|
|
711
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
826
|
+
## Preferences
|
|
810
827
|
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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 =
|
|
132
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
}
|
|
322
|
-
|
|
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) --
|
|
352
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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();
|