pi-daddy 0.15.0 → 0.17.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 +119 -0
- package/README.md +46 -12
- package/dist/chain.d.ts +94 -0
- package/dist/chain.d.ts.map +1 -0
- package/dist/chain.js +161 -0
- package/dist/chain.js.map +1 -0
- package/dist/cli.js +0 -0
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +6 -2
- package/dist/delegate.js.map +1 -1
- package/dist/executor.d.ts +38 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +93 -0
- package/dist/executor.js.map +1 -0
- package/dist/fanout.d.ts +9 -0
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +9 -0
- package/dist/fanout.js.map +1 -1
- package/dist/herdr-cli.d.ts +78 -0
- package/dist/herdr-cli.d.ts.map +1 -0
- package/dist/herdr-cli.js +113 -0
- package/dist/herdr-cli.js.map +1 -0
- package/dist/herdr-name.d.ts +37 -0
- package/dist/herdr-name.d.ts.map +1 -0
- package/dist/herdr-name.js +59 -0
- package/dist/herdr-name.js.map +1 -0
- package/dist/herdr-poll.d.ts +104 -0
- package/dist/herdr-poll.d.ts.map +1 -0
- package/dist/herdr-poll.js +150 -0
- package/dist/herdr-poll.js.map +1 -0
- package/dist/herdr-stage.d.ts +40 -0
- package/dist/herdr-stage.d.ts.map +1 -0
- package/dist/herdr-stage.js +54 -0
- package/dist/herdr-stage.js.map +1 -0
- package/dist/ledger-report.d.ts +18 -0
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +10 -0
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +31 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +2 -0
- package/dist/ledger.js.map +1 -1
- package/dist/pane-reaper.d.ts +66 -4
- package/dist/pane-reaper.d.ts.map +1 -1
- package/dist/pane-reaper.js +131 -9
- package/dist/pane-reaper.js.map +1 -1
- package/dist/progress.d.ts +96 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +167 -0
- package/dist/progress.js.map +1 -0
- package/dist/run-child.d.ts +27 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +84 -7
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +41 -28
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +150 -167
- package/dist/run-herdr.js.map +1 -1
- package/extensions/delegate-chain.ts +357 -0
- package/extensions/delegation.ts +100 -2
- package/extensions/grants-command.ts +26 -1
- package/extensions/grants.ts +85 -163
- package/extensions/run-delegation.ts +130 -13
- package/extensions/session-report.ts +231 -0
- package/extensions/session.ts +63 -11
- package/extensions/tripwire.ts +44 -0
- package/package.json +21 -1
- package/src/chain.ts +174 -0
- package/src/delegate.ts +6 -2
- package/src/executor.ts +122 -0
- package/src/fanout.ts +10 -0
- package/src/herdr-cli.ts +125 -0
- package/src/herdr-name.ts +61 -0
- package/src/herdr-poll.ts +185 -0
- package/src/herdr-stage.ts +55 -0
- package/src/ledger-report.ts +21 -0
- package/src/ledger.ts +33 -0
- package/src/pane-reaper.ts +147 -9
- package/src/progress.ts +206 -0
- package/src/run-child.ts +96 -7
- package/src/run-herdr.ts +170 -174
package/dist/pane-reaper.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Close herdr panes this process opened but never got to close.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* **As of ADR-0032 a pane belongs to the AGENT RUN, not to the tool call.** `runHerdrPane` closes a tab only for
|
|
5
|
+
* a child that did *not* settle — closing the tab is the only kill herdr offers, so a child still working must
|
|
6
|
+
* lose its pane. A child that answered keeps its pane so a human can read it, and this module reaps those: at
|
|
7
|
+
* `agent_settled` (async) and at process `exit` (sync backstop).
|
|
8
|
+
*
|
|
9
|
+
* The gap that remains is the one this module was created for. A pi session **killed outright** runs no `exit`
|
|
10
|
+
* handler, so it leaves one pane per tracked child, and `docs/probes/g16-herdr` records that an orphaned pane is
|
|
11
|
+
* not trivially closable afterwards. R-62 was re-rated L×L → M×L when ADR-0031 made panes the default path.
|
|
7
12
|
*
|
|
8
13
|
* **Registered on `exit` only, deliberately — not on SIGINT or SIGTERM.** That is the part worth reading,
|
|
9
14
|
* because the obvious fix is the dangerous one. Adding a signal listener *suppresses Node's default
|
|
@@ -23,6 +28,24 @@
|
|
|
23
28
|
*/
|
|
24
29
|
import { execFileSync } from "node:child_process";
|
|
25
30
|
import { rmSync } from "node:fs";
|
|
31
|
+
import { rm } from "node:fs/promises";
|
|
32
|
+
import { MAX_CHILDREN_PER_CALL } from "./fanout.js";
|
|
33
|
+
import { defaultExec, parseReply } from "./herdr-cli.js";
|
|
34
|
+
/**
|
|
35
|
+
* How many panes may be open at once — ADR-0032.
|
|
36
|
+
*
|
|
37
|
+
* **Derived from `MAX_CHILDREN_PER_CALL` rather than declared, so the two cannot drift.** The bound is not
|
|
38
|
+
* decoration: `delegate_all` is capped per call and the fan-out budget bounds a subtree, but a plain blocking
|
|
39
|
+
* `delegate` spends **nothing** from that budget by design — so thirty sequential `delegate` calls in one agent
|
|
40
|
+
* run would otherwise hold thirty panes open until it settled.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_OPEN_PANES = MAX_CHILDREN_PER_CALL;
|
|
43
|
+
/** A run has finished with its pane: the trim may now reclaim it. */
|
|
44
|
+
export function markPaneSettled(tab) {
|
|
45
|
+
const pane = open.get(tab);
|
|
46
|
+
if (pane)
|
|
47
|
+
pane.settled = true;
|
|
48
|
+
}
|
|
26
49
|
/**
|
|
27
50
|
* Panes opened by THIS process and not yet closed. Keyed by tab id, so a double close is impossible.
|
|
28
51
|
*
|
|
@@ -92,20 +115,37 @@ export function reapOpenPanes(syncExec = defaultSyncExec, now = Date.now) {
|
|
|
92
115
|
// `openPaneCount()` is non-zero afterwards precisely so a caller could report it if it ever wanted to.
|
|
93
116
|
if (now() >= deadline)
|
|
94
117
|
break;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
// No `agent stop`: it is not a herdr command (see `closePane`). Closing the tab is the kill.
|
|
119
|
+
//
|
|
120
|
+
// A `keepTab` pane is registered only for its staged prompt: remove that and leave the tab alone, which is
|
|
121
|
+
// the whole of what `PI_GRANTS_HERDR_KEEP_PANE=1` promises.
|
|
122
|
+
let closedThisPane = pane.keepTab === true;
|
|
123
|
+
if (pane.keepTab) {
|
|
124
|
+
if (pane.promptDir) {
|
|
125
|
+
try {
|
|
126
|
+
rmSync(pane.promptDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* /tmp litter, not correctness */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
open.delete(pane.tab);
|
|
133
|
+
continue;
|
|
100
134
|
}
|
|
101
135
|
try {
|
|
102
136
|
syncExec(["tab", "close", pane.tab]);
|
|
103
137
|
closed.push(pane.tab);
|
|
138
|
+
closedThisPane = true;
|
|
104
139
|
}
|
|
105
140
|
catch {
|
|
106
141
|
/* an orphan we could not close: `herdr tab close` is the manual remedy, as documented above */
|
|
107
142
|
}
|
|
108
|
-
|
|
143
|
+
// **The staged prompt is deleted only when the tab is provably gone.** It used to be removed
|
|
144
|
+
// unconditionally, so a pane herdr refused to close lost its `--append-system-prompt` file — and pi treats a
|
|
145
|
+
// missing path there as **literal text, with no warning** (`resource-loader.js`: `existsSync` fails, the
|
|
146
|
+
// input is returned). Re-running that pane's echoed argv would hand the child a pathname as its
|
|
147
|
+
// instructions. Litter is cheaper than silently swapping a definition's body for its filename.
|
|
148
|
+
if (closedThisPane && pane.promptDir) {
|
|
109
149
|
try {
|
|
110
150
|
rmSync(pane.promptDir, { recursive: true, force: true });
|
|
111
151
|
}
|
|
@@ -113,8 +153,90 @@ export function reapOpenPanes(syncExec = defaultSyncExec, now = Date.now) {
|
|
|
113
153
|
/* /tmp litter, not correctness */
|
|
114
154
|
}
|
|
115
155
|
}
|
|
156
|
+
// Untracked either way, and only here: at `exit` there is no later sweep, so keeping an unclosable pane
|
|
157
|
+
// registered buys nothing. `closePane`'s async path deliberately differs — another sweep may still run.
|
|
116
158
|
open.delete(pane.tab);
|
|
117
159
|
}
|
|
118
160
|
return closed;
|
|
119
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Close one tracked pane. Shared by the async sweep and the trim so they cannot disagree about what "closed"
|
|
164
|
+
* means — which is the whole of R-62's lesson, one layer up.
|
|
165
|
+
*
|
|
166
|
+
* Returns whether the tab is **provably** gone. A close herdr refused leaves the pane tracked, so the `exit`
|
|
167
|
+
* handler retries it; untracking on a failed close is what disabled the one control built for that failure.
|
|
168
|
+
*/
|
|
169
|
+
async function closePane(exec, pane) {
|
|
170
|
+
// **No `agent stop`.** It is not a herdr command — measured against 0.7.5, where it prints the usage banner
|
|
171
|
+
// and exits 0, which `defaultExec` reports as success. Two call sites here and one in `run-herdr.ts` issued it
|
|
172
|
+
// for nothing, and `docs/probes/g16-herdr` asserted it worked from a block that was never run. Closing the tab
|
|
173
|
+
// is the only kill herdr offers, and it does kill the child.
|
|
174
|
+
const reply = await exec(["tab", "close", pane.tab]).catch(() => undefined);
|
|
175
|
+
const closed = reply !== undefined && !parseReply(reply).error;
|
|
176
|
+
if (!closed)
|
|
177
|
+
return false;
|
|
178
|
+
if (pane.promptDir)
|
|
179
|
+
await rm(pane.promptDir, { recursive: true, force: true }).catch(() => undefined);
|
|
180
|
+
open.delete(pane.tab);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Close every outstanding pane — the `agent_settled` path (ADR-0032).
|
|
185
|
+
*
|
|
186
|
+
* **A separate function from `reapOpenPanes` rather than a shared implementation**, and the reason is not
|
|
187
|
+
* style. The sync one is `execFileSync` with a six-second total budget *by necessity*: an `exit` handler cannot
|
|
188
|
+
* await. Running that at `agent_settled` would freeze pi for up to six seconds **every time the operator gets
|
|
189
|
+
* their prompt back** — turning a feature that exists to make work visible into a stall.
|
|
190
|
+
*
|
|
191
|
+
* Both drain the same `Map`, keyed by tab id, so a double close is impossible and a pane herdr refused stays
|
|
192
|
+
* registered for whichever sweep runs next.
|
|
193
|
+
*
|
|
194
|
+
* Sequential rather than concurrent: a fan-out's panes are at most `MAX_OPEN_PANES`, and eight `tab close`
|
|
195
|
+
* calls in parallel against one herdr server buys nothing worth the burst.
|
|
196
|
+
*/
|
|
197
|
+
export async function reapOpenPanesAsync(exec = defaultExec) {
|
|
198
|
+
const closed = [];
|
|
199
|
+
for (const pane of [...open.values()]) {
|
|
200
|
+
// `keepTab` panes are registered only so their staged prompt can be reaped at `exit`; the tab itself is the
|
|
201
|
+
// operator's to close.
|
|
202
|
+
if (pane.keepTab)
|
|
203
|
+
continue;
|
|
204
|
+
if (await closePane(exec, pane))
|
|
205
|
+
closed.push(pane.tab);
|
|
206
|
+
}
|
|
207
|
+
return closed;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Keep at most `MAX_OPEN_PANES` open, closing the oldest first.
|
|
211
|
+
*
|
|
212
|
+
* The `Map`'s insertion order **is** the age order, so no timestamp is needed — and that is why `trackPane`
|
|
213
|
+
* must never re-`set` an existing tab, which would move it to the back of the queue and make an old pane look
|
|
214
|
+
* new.
|
|
215
|
+
*
|
|
216
|
+
* Whatever it closes is returned so the caller can **say so** rather than silently dropping a pane the operator
|
|
217
|
+
* was reading (R-48's rule, applied to a display).
|
|
218
|
+
*/
|
|
219
|
+
export async function trimOpenPanes(exec = defaultExec) {
|
|
220
|
+
if (open.size <= MAX_OPEN_PANES)
|
|
221
|
+
return [];
|
|
222
|
+
const closed = [];
|
|
223
|
+
// **Only SETTLED panes are candidates, oldest first.** A live sibling's pane is its child's only terminal, and
|
|
224
|
+
// closing a tab kills the child — see `OpenPane.settled`. If every open pane is live the cap is exceeded and
|
|
225
|
+
// nothing is closed, which is the right failure: a pane too many costs an operator a keystroke, and a killed
|
|
226
|
+
// child costs them the work.
|
|
227
|
+
//
|
|
228
|
+
// **Walks past what it cannot close**, rather than attempting `excess` panes from the front and calling it
|
|
229
|
+
// done. A pane herdr refuses to close used to sit at the head forever, so the cap silently stopped holding
|
|
230
|
+
// (measured: 30 panes for 30 delegates when every close was refused) and the same corpse was re-attacked on
|
|
231
|
+
// every spawn — O(n²) round-trips.
|
|
232
|
+
for (const pane of [...open.values()]) {
|
|
233
|
+
if (open.size - closed.length <= MAX_OPEN_PANES)
|
|
234
|
+
break;
|
|
235
|
+
if (!pane.settled || pane.keepTab)
|
|
236
|
+
continue;
|
|
237
|
+
if (await closePane(exec, pane))
|
|
238
|
+
closed.push(pane.tab);
|
|
239
|
+
}
|
|
240
|
+
return closed;
|
|
241
|
+
}
|
|
120
242
|
//# sourceMappingURL=pane-reaper.js.map
|
package/dist/pane-reaper.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pane-reaper.js","sourceRoot":"","sources":["../src/pane-reaper.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"pane-reaper.js","sourceRoot":"","sources":["../src/pane-reaper.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status block a running delegation renders — ADR-0032, in the shape the operator chose on 2026-08-17.
|
|
3
|
+
*
|
|
4
|
+
* Three properties are load-bearing rather than cosmetic, and each has a test:
|
|
5
|
+
*
|
|
6
|
+
* - **Bounded height AND bounded width.** Five lines per child — header, up to `TAIL_LINES` of tail, and a
|
|
7
|
+
* blank separator — so eight children is at most 42 lines, plus `MAX_LINE_CHARS` per line. The width half was
|
|
8
|
+
* missing and the omission mattered: a line cap is what actually bounds the block, because eight children
|
|
9
|
+
* each printing one megabyte-long line rendered 25 lines and 8 MiB. "Four lines per child" was also simply
|
|
10
|
+
* wrong arithmetic — the separator was never counted.
|
|
11
|
+
* - **No braiding.** Each child's text stays under its own header, which is what a chronological stream of two
|
|
12
|
+
* concurrent children cannot offer.
|
|
13
|
+
* - **The pane id is on screen while the child is alive.** That is the entire difference between a pane you can
|
|
14
|
+
* switch to and one you learn about after it closed.
|
|
15
|
+
*
|
|
16
|
+
* What it gives up is stated rather than implied (R-48): output older than the last `TAIL_LINES` lines is not
|
|
17
|
+
* here. It is in the pane while the pane lives, and in the returned result afterwards. **The block is a display
|
|
18
|
+
* and never the result** — conflating the two is R-03 with a new cause.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no pi, no herdr, and no clock. `now` is a parameter so elapsed time is testable.
|
|
21
|
+
*/
|
|
22
|
+
/** Lines of context kept per child. Three, because four children of four lines each is already a screenful. */
|
|
23
|
+
export declare const TAIL_LINES = 3;
|
|
24
|
+
/**
|
|
25
|
+
* Longest a single displayed line may be.
|
|
26
|
+
*
|
|
27
|
+
* **The line COUNT cap was never a height cap, and this is what makes the module header's claim true.** A child
|
|
28
|
+
* printing a minified bundle, a base64 blob or single-line JSON produces one line of megabytes, so
|
|
29
|
+
* `TAIL_LINES` bounded nothing that matters: eight such children rendered **25 lines and 8 MiB — about 84,000
|
|
30
|
+
* wrapped rows** — and were repainted every `PAINT_INTERVAL_MS`. Measured. The test asserting "fixed height"
|
|
31
|
+
* passed throughout, because 25 ≤ 34.
|
|
32
|
+
*
|
|
33
|
+
* 200 is a little over two rows on a wide terminal: enough to read a long path or a stack frame, short enough
|
|
34
|
+
* that eight children cannot flood the screen.
|
|
35
|
+
*/
|
|
36
|
+
export declare const MAX_LINE_CHARS = 200;
|
|
37
|
+
export type ChildState = "starting" | "running" | "completed" | "failed";
|
|
38
|
+
/**
|
|
39
|
+
* The last few lines a child printed, plus whether the final one is still being written.
|
|
40
|
+
*
|
|
41
|
+
* **`open` is why this is not a bare `string[]`.** A pipe delivers bytes, not lines, so `Read` and
|
|
42
|
+
* `ing file.ts` arrive as two chunks and must render as one line — and knowing whether to join or to start a new
|
|
43
|
+
* line is state that `(lines, chunk)` alone cannot carry. The first draft encoded it as a trailing-space
|
|
44
|
+
* sentinel inside the array, which worked and was unreadable; a named boolean is the same information without
|
|
45
|
+
* the puzzle.
|
|
46
|
+
*/
|
|
47
|
+
export interface Tail {
|
|
48
|
+
lines: string[];
|
|
49
|
+
/** True when the last element is a partial line awaiting more bytes. */
|
|
50
|
+
open: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare const emptyTail: Tail;
|
|
53
|
+
export interface ChildProgress {
|
|
54
|
+
/** Definition name, or `delegate` for a `tools:` spawn. */
|
|
55
|
+
label: string;
|
|
56
|
+
/** herdr agent name, when this child runs in a pane. */
|
|
57
|
+
agentName?: string;
|
|
58
|
+
paneId?: string;
|
|
59
|
+
state: ChildState;
|
|
60
|
+
startedAt: number;
|
|
61
|
+
/** Set once terminal, so elapsed time freezes instead of counting forever. */
|
|
62
|
+
settledAt?: number;
|
|
63
|
+
tail: Tail;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fold a raw chunk into a tail.
|
|
67
|
+
*
|
|
68
|
+
* Blank lines are dropped so a child printing newlines cannot blank the block — at three lines of context, four
|
|
69
|
+
* newlines would erase everything the operator was reading.
|
|
70
|
+
*/
|
|
71
|
+
export declare function appendTail(tail: Tail, chunk: string): Tail;
|
|
72
|
+
/**
|
|
73
|
+
* Replace a tail wholesale from a pane SNAPSHOT — the herdr path's counterpart to `appendTail`.
|
|
74
|
+
*
|
|
75
|
+
* `agent read` returns a snapshot of a bounded terminal, so the right operation is *replace*, not *append*.
|
|
76
|
+
* Appending snapshots is what fabricated text the child never printed: a pane whose bottom line was
|
|
77
|
+
* `working on step 29` followed by a re-report beginning `line30` rendered `working on step 29line30`.
|
|
78
|
+
* Measured. `open` is always false here, because a snapshot is never mid-line as far as the display is
|
|
79
|
+
* concerned — there is nothing further to join onto.
|
|
80
|
+
*/
|
|
81
|
+
export declare function replaceTail(lines: string[]): Tail;
|
|
82
|
+
export declare function renderProgress(children: ChildProgress[], executorKind: "herdr" | "process", now: number): string;
|
|
83
|
+
/** How often the block is repainted. A child printing fast must not re-render it hundreds of times a second. */
|
|
84
|
+
export declare const PAINT_INTERVAL_MS = 250;
|
|
85
|
+
/**
|
|
86
|
+
* Call `fn` at most once per `intervalMs`, and always once more after the last call.
|
|
87
|
+
*
|
|
88
|
+
* The trailing call is the point: without it the final frame — the one showing every child settled — is the one
|
|
89
|
+
* most likely to be dropped, so the block would freeze mid-run and never show completion. `flush` exists so a
|
|
90
|
+
* caller that knows it is finished can paint immediately rather than waiting out the interval.
|
|
91
|
+
*/
|
|
92
|
+
export declare function throttle(fn: () => void, intervalMs: number): {
|
|
93
|
+
call: () => void;
|
|
94
|
+
flush: () => void;
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=progress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"progress.d.ts","sourceRoot":"","sources":["../src/progress.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,+GAA+G;AAC/G,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,MAAM,CAAC;AAQlC,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,CAAC;AAEzE;;;;;;;;GAQG;AACH,MAAM,WAAW,IAAI;IACnB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,wEAAwE;IACxE,IAAI,EAAE,OAAO,CAAC;CACf;AAED,eAAO,MAAM,SAAS,EAAE,IAAiC,CAAC;AAE1D,MAAM,WAAW,aAAa;IAC5B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,IAAI,CAAC;CACZ;AAaD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAsB1D;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAEjD;AAmBD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,aAAa,EAAE,EAAE,YAAY,EAAE,OAAO,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAmBhH;AAED,gHAAgH;AAChH,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAAE,CA0BpG"}
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status block a running delegation renders — ADR-0032, in the shape the operator chose on 2026-08-17.
|
|
3
|
+
*
|
|
4
|
+
* Three properties are load-bearing rather than cosmetic, and each has a test:
|
|
5
|
+
*
|
|
6
|
+
* - **Bounded height AND bounded width.** Five lines per child — header, up to `TAIL_LINES` of tail, and a
|
|
7
|
+
* blank separator — so eight children is at most 42 lines, plus `MAX_LINE_CHARS` per line. The width half was
|
|
8
|
+
* missing and the omission mattered: a line cap is what actually bounds the block, because eight children
|
|
9
|
+
* each printing one megabyte-long line rendered 25 lines and 8 MiB. "Four lines per child" was also simply
|
|
10
|
+
* wrong arithmetic — the separator was never counted.
|
|
11
|
+
* - **No braiding.** Each child's text stays under its own header, which is what a chronological stream of two
|
|
12
|
+
* concurrent children cannot offer.
|
|
13
|
+
* - **The pane id is on screen while the child is alive.** That is the entire difference between a pane you can
|
|
14
|
+
* switch to and one you learn about after it closed.
|
|
15
|
+
*
|
|
16
|
+
* What it gives up is stated rather than implied (R-48): output older than the last `TAIL_LINES` lines is not
|
|
17
|
+
* here. It is in the pane while the pane lives, and in the returned result afterwards. **The block is a display
|
|
18
|
+
* and never the result** — conflating the two is R-03 with a new cause.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no pi, no herdr, and no clock. `now` is a parameter so elapsed time is testable.
|
|
21
|
+
*/
|
|
22
|
+
/** Lines of context kept per child. Three, because four children of four lines each is already a screenful. */
|
|
23
|
+
export const TAIL_LINES = 3;
|
|
24
|
+
/**
|
|
25
|
+
* Longest a single displayed line may be.
|
|
26
|
+
*
|
|
27
|
+
* **The line COUNT cap was never a height cap, and this is what makes the module header's claim true.** A child
|
|
28
|
+
* printing a minified bundle, a base64 blob or single-line JSON produces one line of megabytes, so
|
|
29
|
+
* `TAIL_LINES` bounded nothing that matters: eight such children rendered **25 lines and 8 MiB — about 84,000
|
|
30
|
+
* wrapped rows** — and were repainted every `PAINT_INTERVAL_MS`. Measured. The test asserting "fixed height"
|
|
31
|
+
* passed throughout, because 25 ≤ 34.
|
|
32
|
+
*
|
|
33
|
+
* 200 is a little over two rows on a wide terminal: enough to read a long path or a stack frame, short enough
|
|
34
|
+
* that eight children cannot flood the screen.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_LINE_CHARS = 200;
|
|
37
|
+
/** Trim one display line to `MAX_LINE_CHARS`, saying so rather than cutting silently (R-48). */
|
|
38
|
+
function clampLine(line) {
|
|
39
|
+
if (line.length <= MAX_LINE_CHARS)
|
|
40
|
+
return line;
|
|
41
|
+
return `${line.slice(0, MAX_LINE_CHARS)}… (+${line.length - MAX_LINE_CHARS} chars)`;
|
|
42
|
+
}
|
|
43
|
+
export const emptyTail = { lines: [], open: false };
|
|
44
|
+
/**
|
|
45
|
+
* Collapse a terminal's carriage returns the way a terminal would: what follows the last `\r` wins.
|
|
46
|
+
*
|
|
47
|
+
* A spinner writes `working \r working \r done`. Splitting on `\r` as if it were a newline would fill the whole
|
|
48
|
+
* three-line tail with one frame per tick and push the child's real output out of the block.
|
|
49
|
+
*/
|
|
50
|
+
function lastAfterCarriageReturn(line) {
|
|
51
|
+
const at = line.lastIndexOf("\r");
|
|
52
|
+
return at === -1 ? line : line.slice(at + 1);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fold a raw chunk into a tail.
|
|
56
|
+
*
|
|
57
|
+
* Blank lines are dropped so a child printing newlines cannot blank the block — at three lines of context, four
|
|
58
|
+
* newlines would erase everything the operator was reading.
|
|
59
|
+
*/
|
|
60
|
+
export function appendTail(tail, chunk) {
|
|
61
|
+
if (chunk.length === 0)
|
|
62
|
+
return tail;
|
|
63
|
+
const parts = chunk.split("\n");
|
|
64
|
+
const lines = [...tail.lines];
|
|
65
|
+
// The first part continues the previous line when that line was left open.
|
|
66
|
+
//
|
|
67
|
+
// **Clamped on every write, not only at render.** A child that never emits a newline keeps extending one
|
|
68
|
+
// line, and an unclamped join grew it without bound — measured at 12,692 characters from a pane that never
|
|
69
|
+
// held more than 16. Clamping here also bounds what is retained, not merely what is shown.
|
|
70
|
+
if (tail.open && lines.length > 0) {
|
|
71
|
+
lines[lines.length - 1] = clampLine(lastAfterCarriageReturn(lines[lines.length - 1] + parts[0]));
|
|
72
|
+
}
|
|
73
|
+
else if (parts[0].trim().length > 0) {
|
|
74
|
+
lines.push(clampLine(lastAfterCarriageReturn(parts[0])));
|
|
75
|
+
}
|
|
76
|
+
for (const part of parts.slice(1)) {
|
|
77
|
+
if (part.trim().length > 0)
|
|
78
|
+
lines.push(clampLine(lastAfterCarriageReturn(part)));
|
|
79
|
+
}
|
|
80
|
+
return { lines: lines.slice(-TAIL_LINES), open: !chunk.endsWith("\n") };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Replace a tail wholesale from a pane SNAPSHOT — the herdr path's counterpart to `appendTail`.
|
|
84
|
+
*
|
|
85
|
+
* `agent read` returns a snapshot of a bounded terminal, so the right operation is *replace*, not *append*.
|
|
86
|
+
* Appending snapshots is what fabricated text the child never printed: a pane whose bottom line was
|
|
87
|
+
* `working on step 29` followed by a re-report beginning `line30` rendered `working on step 29line30`.
|
|
88
|
+
* Measured. `open` is always false here, because a snapshot is never mid-line as far as the display is
|
|
89
|
+
* concerned — there is nothing further to join onto.
|
|
90
|
+
*/
|
|
91
|
+
export function replaceTail(lines) {
|
|
92
|
+
return { lines: lines.slice(-TAIL_LINES).map(clampLine), open: false };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A child label is a DIRECTORY name, so it is third-party text on a line this package composes.
|
|
96
|
+
*
|
|
97
|
+
* R-77 and R-78 were both "a name from somewhere else reached a generated artefact"; here a newline forges an
|
|
98
|
+
* entire extra child row in the operator's status block, complete with a plausible agent and pane. Same class,
|
|
99
|
+
* same treatment as `spawn-summary.ts`'s `safeName`: rendered inert rather than trusted.
|
|
100
|
+
*/
|
|
101
|
+
function safeLabel(label) {
|
|
102
|
+
return /[\n\r\t]/.test(label) ? JSON.stringify(label) : label;
|
|
103
|
+
}
|
|
104
|
+
/** `m:ss`. A delegation is minutes-scale, and an hour-long one has problems this line will not explain. */
|
|
105
|
+
function elapsed(child, now) {
|
|
106
|
+
const total = Math.floor(Math.max(0, (child.settledAt ?? now) - child.startedAt) / 1000);
|
|
107
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
|
108
|
+
}
|
|
109
|
+
export function renderProgress(children, executorKind, now) {
|
|
110
|
+
const where = executorKind === "herdr" ? "herdr panes" : "captured subprocesses";
|
|
111
|
+
const lines = [`${children.length} ${children.length === 1 ? "child" : "children"} · ${where}`, ""];
|
|
112
|
+
for (const child of children) {
|
|
113
|
+
const header = [safeLabel(child.label).padEnd(10)];
|
|
114
|
+
if (child.agentName)
|
|
115
|
+
header.push(`agent ${child.agentName}`);
|
|
116
|
+
if (child.paneId)
|
|
117
|
+
header.push(`pane ${child.paneId}`);
|
|
118
|
+
header.push(child.state, elapsed(child, now));
|
|
119
|
+
lines.push(header.join(" "));
|
|
120
|
+
// Sliced again here as well as in `appendTail`, so a caller that assembled a `Tail` by hand cannot make the
|
|
121
|
+
// block unbounded. The height guarantee belongs to the renderer, not to its inputs.
|
|
122
|
+
// Clamped again here as well as on write, so a caller that assembled a `Tail` by hand cannot make the block
|
|
123
|
+
// unbounded either. The height guarantee belongs to the renderer, not to its inputs.
|
|
124
|
+
for (const line of child.tail.lines.slice(-TAIL_LINES))
|
|
125
|
+
lines.push(` ${clampLine(line)}`);
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n").trimEnd();
|
|
129
|
+
}
|
|
130
|
+
/** How often the block is repainted. A child printing fast must not re-render it hundreds of times a second. */
|
|
131
|
+
export const PAINT_INTERVAL_MS = 250;
|
|
132
|
+
/**
|
|
133
|
+
* Call `fn` at most once per `intervalMs`, and always once more after the last call.
|
|
134
|
+
*
|
|
135
|
+
* The trailing call is the point: without it the final frame — the one showing every child settled — is the one
|
|
136
|
+
* most likely to be dropped, so the block would freeze mid-run and never show completion. `flush` exists so a
|
|
137
|
+
* caller that knows it is finished can paint immediately rather than waiting out the interval.
|
|
138
|
+
*/
|
|
139
|
+
export function throttle(fn, intervalMs) {
|
|
140
|
+
let last = 0;
|
|
141
|
+
let timer;
|
|
142
|
+
const run = (at) => {
|
|
143
|
+
last = at;
|
|
144
|
+
timer = undefined;
|
|
145
|
+
fn();
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
call: () => {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
if (now - last >= intervalMs)
|
|
151
|
+
return run(now);
|
|
152
|
+
// Already scheduled: the pending call will render whatever state exists when it fires, which is newer
|
|
153
|
+
// than this one. Queueing another would only repaint the same thing twice.
|
|
154
|
+
if (timer)
|
|
155
|
+
return;
|
|
156
|
+
timer = setTimeout(() => run(Date.now()), intervalMs - (now - last)).unref?.();
|
|
157
|
+
},
|
|
158
|
+
flush: () => {
|
|
159
|
+
if (timer)
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
timer = undefined;
|
|
162
|
+
last = Date.now();
|
|
163
|
+
fn();
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"progress.js","sourceRoot":"","sources":["../src/progress.ts"],"names":[],"mappings":"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"}
|
package/dist/run-child.d.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
*
|
|
9
9
|
* It lives here, out of `extensions/grants.ts`, so it can be tested against real processes without pi.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* The longest prefix of `text` that fits in `budget` BYTES, never splitting a character.
|
|
13
|
+
*
|
|
14
|
+
* Both halves matter. Truncating by `String.prototype.slice` counts UTF-16 code units against a byte budget,
|
|
15
|
+
* which overruns by the encoding width of whatever the child printed. Truncating the *buffer* instead would
|
|
16
|
+
* respect the budget and split a multi-byte character or a surrogate pair, putting a lone `\ud83d` in the
|
|
17
|
+
* result. So the walk is by code POINT (`for…of` iterates code points, keeping surrogate pairs whole) and the
|
|
18
|
+
* budget is checked in bytes before each one is admitted.
|
|
19
|
+
*
|
|
20
|
+
* Exported for the test that feeds it CJK and emoji: an ASCII-only test cannot fail on any of this, which is
|
|
21
|
+
* exactly why the original defect survived a test named for it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function takeBytes(text: string, budget: number): string;
|
|
11
24
|
/**
|
|
12
25
|
* Operator override for the child wall-clock limit, in seconds.
|
|
13
26
|
*
|
|
@@ -29,6 +42,20 @@ export interface ChildRunRequest {
|
|
|
29
42
|
/** Wall-clock cap. On expiry: SIGTERM, then SIGKILL after `killGraceMs`. */
|
|
30
43
|
timeoutMs?: number;
|
|
31
44
|
killGraceMs?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Called with each chunk as it arrives, for the parent's progress display (ADR-0032).
|
|
47
|
+
*
|
|
48
|
+
* **Display only, and the distinction is load-bearing.** The child's answer is still `text`, assembled here
|
|
49
|
+
* and returned; a caller must never treat what it saw streamed as the result. A partial stream that could be
|
|
50
|
+
* mistaken for a complete answer is R-03's defect — a missing result indistinguishable from an empty one —
|
|
51
|
+
* with a new cause.
|
|
52
|
+
*
|
|
53
|
+
* Bounded by the same `maxOutputBytes` as `text`, so the cap governs the transcript and not merely memory.
|
|
54
|
+
*
|
|
55
|
+
* Exceptions are swallowed: a renderer is not a governance control, and one that throws must not kill a
|
|
56
|
+
* governed child mid-task.
|
|
57
|
+
*/
|
|
58
|
+
onOutput?: (chunk: string) => void;
|
|
32
59
|
}
|
|
33
60
|
export interface ChildRunResult {
|
|
34
61
|
/** Exit code, or `null` when nothing was spawned or the child was killed by a signal. */
|
package/dist/run-child.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-child.d.ts","sourceRoot":"","sources":["../src/run-child.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;
|
|
1
|
+
{"version":3,"file":"run-child.d.ts","sourceRoot":"","sources":["../src/run-child.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAY9D;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,4BAA4B,CAAC;AAE3D,iGAAiG;AACjG,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAK9D;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,yFAAyF;IACzF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,yFAAyF;IACzF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,0FAA0F;AAC1F,eAAO,MAAM,wBAAwB,QAAc,CAAC;AACpD,kGAAkG;AAClG,eAAO,MAAM,kBAAkB,QAAiB,CAAC;AACjD,0GAA0G;AAC1G,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,wBAAgB,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CAsI1E"}
|
package/dist/run-child.js
CHANGED
|
@@ -9,7 +9,36 @@
|
|
|
9
9
|
* It lives here, out of `extensions/grants.ts`, so it can be tested against real processes without pi.
|
|
10
10
|
*/
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
|
+
import { StringDecoder } from "node:string_decoder";
|
|
12
13
|
import { parseBound } from "./propagation.js";
|
|
14
|
+
/**
|
|
15
|
+
* The longest prefix of `text` that fits in `budget` BYTES, never splitting a character.
|
|
16
|
+
*
|
|
17
|
+
* Both halves matter. Truncating by `String.prototype.slice` counts UTF-16 code units against a byte budget,
|
|
18
|
+
* which overruns by the encoding width of whatever the child printed. Truncating the *buffer* instead would
|
|
19
|
+
* respect the budget and split a multi-byte character or a surrogate pair, putting a lone `\ud83d` in the
|
|
20
|
+
* result. So the walk is by code POINT (`for…of` iterates code points, keeping surrogate pairs whole) and the
|
|
21
|
+
* budget is checked in bytes before each one is admitted.
|
|
22
|
+
*
|
|
23
|
+
* Exported for the test that feeds it CJK and emoji: an ASCII-only test cannot fail on any of this, which is
|
|
24
|
+
* exactly why the original defect survived a test named for it.
|
|
25
|
+
*/
|
|
26
|
+
export function takeBytes(text, budget) {
|
|
27
|
+
if (budget <= 0)
|
|
28
|
+
return "";
|
|
29
|
+
if (Buffer.byteLength(text) <= budget)
|
|
30
|
+
return text;
|
|
31
|
+
let out = "";
|
|
32
|
+
let used = 0;
|
|
33
|
+
for (const character of text) {
|
|
34
|
+
const size = Buffer.byteLength(character);
|
|
35
|
+
if (used + size > budget)
|
|
36
|
+
break;
|
|
37
|
+
out += character;
|
|
38
|
+
used += size;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
13
42
|
/**
|
|
14
43
|
* Operator override for the child wall-clock limit, in seconds.
|
|
15
44
|
*
|
|
@@ -54,6 +83,9 @@ export function runChild(request) {
|
|
|
54
83
|
settle({ code: null, text: "", truncated: false, timedOut: false, aborted: false, spawnError: String(error) });
|
|
55
84
|
return;
|
|
56
85
|
}
|
|
86
|
+
// One decoder for BOTH streams is deliberate: they are already interleaved into one `text`, and two
|
|
87
|
+
// decoders would each hold their own partial character, so a split byte could surface out of order.
|
|
88
|
+
const decoder = new StringDecoder("utf8");
|
|
57
89
|
let text = "";
|
|
58
90
|
let bytes = 0;
|
|
59
91
|
let truncated = false;
|
|
@@ -67,21 +99,66 @@ export function runChild(request) {
|
|
|
67
99
|
child.kill("SIGTERM");
|
|
68
100
|
timers.push(setTimeout(() => child.kill("SIGKILL"), killGraceMs));
|
|
69
101
|
};
|
|
102
|
+
/** How much of `text` the progress display has already been shown. */
|
|
103
|
+
let emittedUpTo = 0;
|
|
104
|
+
/**
|
|
105
|
+
* Stream whatever `text` has gained since the last call.
|
|
106
|
+
*
|
|
107
|
+
* **Derived from `text` rather than from the incoming chunk**, and that is the whole correctness argument:
|
|
108
|
+
* what gets streamed is then *by construction* a prefix of what gets returned, so the output cap bounds the
|
|
109
|
+
* parent's transcript exactly as it bounds the result. The first version emitted the raw chunk and pushed
|
|
110
|
+
* 1924 bytes through a 1024-byte cap on the truncating write — the cap bounded memory and not the screen
|
|
111
|
+
* it had just been extended to protect. Found by the test, not by reading it.
|
|
112
|
+
*
|
|
113
|
+
* Swallowing is deliberate: `onOutput` renders, and a renderer that throws must not become a way to kill a
|
|
114
|
+
* governed child. Nothing downstream depends on it having run.
|
|
115
|
+
*/
|
|
116
|
+
const flush = () => {
|
|
117
|
+
if (!request.onOutput || text.length <= emittedUpTo)
|
|
118
|
+
return;
|
|
119
|
+
const chunk = text.slice(emittedUpTo);
|
|
120
|
+
emittedUpTo = text.length;
|
|
121
|
+
try {
|
|
122
|
+
request.onOutput(chunk);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* display only */
|
|
126
|
+
}
|
|
127
|
+
};
|
|
70
128
|
const capture = (chunk) => {
|
|
71
129
|
if (truncated)
|
|
72
130
|
return;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
131
|
+
// **Decoded through a StringDecoder, not `String(chunk)`.** A pipe splits on byte boundaries, not
|
|
132
|
+
// character ones, so a multi-byte character straddling two `data` events was decoded as two invalid
|
|
133
|
+
// halves and became U+FFFD — in the child's ANSWER, not merely the display. Measured: 300,000 bytes of
|
|
134
|
+
// CJK produced **twelve** replacement characters, because 65536 % 3 == 1. Emoji happened to survive
|
|
135
|
+
// (65536 % 4 == 0), which is why width-dependent corruption went unnoticed. The decoder holds the
|
|
136
|
+
// partial bytes until the rest arrives.
|
|
137
|
+
const s = decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
138
|
+
if (s.length === 0)
|
|
139
|
+
return;
|
|
140
|
+
const size = Buffer.byteLength(s);
|
|
141
|
+
if (bytes + size > maxOutputBytes) {
|
|
142
|
+
// Keep what fits, mark it, and stop the child: an unbounded producer must not be able to exhaust the
|
|
143
|
+
// orchestrator's memory just because it was granted a tool that prints.
|
|
144
|
+
//
|
|
145
|
+
// **Trimmed by BYTES, and that is a fix rather than a refinement.** This was
|
|
146
|
+
// `text.slice(0, maxOutputBytes)` — a count of UTF-16 code units against a budget measured in bytes,
|
|
147
|
+
// so any non-ASCII output overran the cap by its own encoding width: 300 bytes of CJK through a
|
|
148
|
+
// 100-byte cap, and a measured **2048 bytes through the real 1024 default**. It is the same defect as
|
|
149
|
+
// the 1924-byte one already fixed here, one layer down: that fix bounded the *unit* count, and the
|
|
150
|
+
// budget was never in units. An odd cap also split a surrogate pair, putting a lone `\ud83d` into
|
|
151
|
+
// both the transcript and the returned answer.
|
|
152
|
+
text += takeBytes(s, maxOutputBytes - bytes);
|
|
153
|
+
bytes = maxOutputBytes;
|
|
80
154
|
truncated = true;
|
|
155
|
+
flush();
|
|
81
156
|
stop();
|
|
82
157
|
return;
|
|
83
158
|
}
|
|
159
|
+
bytes += size;
|
|
84
160
|
text += s;
|
|
161
|
+
flush();
|
|
85
162
|
};
|
|
86
163
|
child.stdout?.on("data", capture);
|
|
87
164
|
child.stderr?.on("data", capture);
|