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.
Files changed (81) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +46 -12
  3. package/dist/chain.d.ts +94 -0
  4. package/dist/chain.d.ts.map +1 -0
  5. package/dist/chain.js +161 -0
  6. package/dist/chain.js.map +1 -0
  7. package/dist/cli.js +0 -0
  8. package/dist/delegate.d.ts.map +1 -1
  9. package/dist/delegate.js +6 -2
  10. package/dist/delegate.js.map +1 -1
  11. package/dist/executor.d.ts +38 -0
  12. package/dist/executor.d.ts.map +1 -0
  13. package/dist/executor.js +93 -0
  14. package/dist/executor.js.map +1 -0
  15. package/dist/fanout.d.ts +9 -0
  16. package/dist/fanout.d.ts.map +1 -1
  17. package/dist/fanout.js +9 -0
  18. package/dist/fanout.js.map +1 -1
  19. package/dist/herdr-cli.d.ts +78 -0
  20. package/dist/herdr-cli.d.ts.map +1 -0
  21. package/dist/herdr-cli.js +113 -0
  22. package/dist/herdr-cli.js.map +1 -0
  23. package/dist/herdr-name.d.ts +37 -0
  24. package/dist/herdr-name.d.ts.map +1 -0
  25. package/dist/herdr-name.js +59 -0
  26. package/dist/herdr-name.js.map +1 -0
  27. package/dist/herdr-poll.d.ts +104 -0
  28. package/dist/herdr-poll.d.ts.map +1 -0
  29. package/dist/herdr-poll.js +150 -0
  30. package/dist/herdr-poll.js.map +1 -0
  31. package/dist/herdr-stage.d.ts +40 -0
  32. package/dist/herdr-stage.d.ts.map +1 -0
  33. package/dist/herdr-stage.js +54 -0
  34. package/dist/herdr-stage.js.map +1 -0
  35. package/dist/ledger-report.d.ts +18 -0
  36. package/dist/ledger-report.d.ts.map +1 -1
  37. package/dist/ledger-report.js +10 -0
  38. package/dist/ledger-report.js.map +1 -1
  39. package/dist/ledger.d.ts +31 -0
  40. package/dist/ledger.d.ts.map +1 -1
  41. package/dist/ledger.js +2 -0
  42. package/dist/ledger.js.map +1 -1
  43. package/dist/pane-reaper.d.ts +66 -4
  44. package/dist/pane-reaper.d.ts.map +1 -1
  45. package/dist/pane-reaper.js +131 -9
  46. package/dist/pane-reaper.js.map +1 -1
  47. package/dist/progress.d.ts +96 -0
  48. package/dist/progress.d.ts.map +1 -0
  49. package/dist/progress.js +167 -0
  50. package/dist/progress.js.map +1 -0
  51. package/dist/run-child.d.ts +27 -0
  52. package/dist/run-child.d.ts.map +1 -1
  53. package/dist/run-child.js +84 -7
  54. package/dist/run-child.js.map +1 -1
  55. package/dist/run-herdr.d.ts +41 -28
  56. package/dist/run-herdr.d.ts.map +1 -1
  57. package/dist/run-herdr.js +150 -167
  58. package/dist/run-herdr.js.map +1 -1
  59. package/extensions/delegate-chain.ts +357 -0
  60. package/extensions/delegation.ts +100 -2
  61. package/extensions/grants-command.ts +26 -1
  62. package/extensions/grants.ts +85 -163
  63. package/extensions/run-delegation.ts +130 -13
  64. package/extensions/session-report.ts +231 -0
  65. package/extensions/session.ts +63 -11
  66. package/extensions/tripwire.ts +44 -0
  67. package/package.json +21 -1
  68. package/src/chain.ts +174 -0
  69. package/src/delegate.ts +6 -2
  70. package/src/executor.ts +122 -0
  71. package/src/fanout.ts +10 -0
  72. package/src/herdr-cli.ts +125 -0
  73. package/src/herdr-name.ts +61 -0
  74. package/src/herdr-poll.ts +185 -0
  75. package/src/herdr-stage.ts +55 -0
  76. package/src/ledger-report.ts +21 -0
  77. package/src/ledger.ts +33 -0
  78. package/src/pane-reaper.ts +147 -9
  79. package/src/progress.ts +206 -0
  80. package/src/run-child.ts +96 -7
  81. package/src/run-herdr.ts +170 -174
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Close herdr panes this process opened but never got to close.
3
3
  *
4
- * `runHerdrPane` closes its pane in a `finally`, which covers a thrown error and a timeout — **not the
5
- * process being killed**. A pi session interrupted mid-fan-out left one pane per in-flight child, and
6
- * `docs/probes/g16-herdr` records that an orphaned pane is not trivially closable afterwards.
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
- try {
96
- syncExec(["agent", "stop", pane.name]);
97
- }
98
- catch {
99
- /* the agent may already be gone; the tab is what matters */
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
- if (pane.promptDir) {
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
@@ -1 +1 @@
1
- {"version":3,"file":"pane-reaper.js","sourceRoot":"","sources":["../src/pane-reaper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAWjC;;;;;;;;;GASG;AACH,MAAM,IAAI,GAAG,IAAI,GAAG,EAAoB,CAAC;AACzC,IAAI,aAAa,GAAG,KAAK,CAAC;AAE1B,iGAAiG;AACjG,MAAM,UAAU,SAAS,CAAC,IAAc;IACtC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACzB,IAAI,aAAa;QAAE,OAAO;IAC1B,aAAa,GAAG,IAAI,CAAC;IACrB,qGAAqG;IACrG,oGAAoG;IACpG,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,aAAa,EAAE,CAAC,CAAC;AACnD,CAAC;AAED,gDAAgD;AAChD,MAAM,UAAU,WAAW,CAAC,GAAW;IACrC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AACnB,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,aAAa;IAC3B,OAAO,IAAI,CAAC,IAAI,CAAC;AACnB,CAAC;AAED,wGAAwG;AACxG,MAAM,WAAW,GAAG,IAAI,CAAC;AAEzB;;;;;;;;;;;;GAYG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC;AAE7B,sGAAsG;AACtG,MAAM,eAAe,GAAG,CAAC,IAAc,EAAQ,EAAE;IAC/C,YAAY,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,SAAS,EAAE,CAAC,CAAC;AAChG,CAAC,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,WAAqC,eAAe,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG;IAChG,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,QAAQ,GAAG,GAAG,EAAE,GAAG,eAAe,CAAC;IACzC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC;QACtC,sGAAsG;QACtG,qGAAqG;QACrG,uGAAuG;QACvG,IAAI,GAAG,EAAE,IAAI,QAAQ;YAAE,MAAM;QAC7B,IAAI,CAAC;YACH,QAAQ,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,CAAC;QAAC,MAAM,CAAC;YACP,4DAA4D;QAC9D,CAAC;QACD,IAAI,CAAC;YACH,QAAQ,CAAC,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;YACrC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACxB,CAAC;QAAC,MAAM,CAAC;YACP,+FAA+F;QACjG,CAAC;QACD,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACnB,IAAI,CAAC;gBACH,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3D,CAAC;YAAC,MAAM,CAAC;gBACP,kCAAkC;YACpC,CAAC;QACH,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
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"}
@@ -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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"}
@@ -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. */
@@ -1 +1 @@
1
- {"version":3,"file":"run-child.d.ts","sourceRoot":"","sources":["../src/run-child.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH;;;;;;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;CACtB;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,CAsF1E"}
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
- const s = String(chunk);
74
- bytes += Buffer.byteLength(s);
75
- if (bytes > maxOutputBytes) {
76
- // Keep what fits, mark it, and stop the child: an unbounded producer must not be able to
77
- // exhaust the orchestrator's memory just because it was granted a tool that prints.
78
- text += s;
79
- text = text.slice(0, maxOutputBytes);
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);