@crewhaus/tool-proc 0.7.0 → 0.8.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.
@@ -3,6 +3,7 @@
3
3
  * reads the filesystem or looks at a clock, so the behaviour that these
4
4
  * back can be unit-tested without a world.
5
5
  */
6
+ import { compileUserRegex } from "@crewhaus/tool-safety/regex";
6
7
  /**
7
8
  * Cap a captured stream to a character budget, keeping BOTH ends.
8
9
  *
@@ -37,88 +38,37 @@ export function formatArgv(argv) {
37
38
  /**
38
39
  * Compile a caller-supplied regular expression, refusing the shapes behind
39
40
  * catastrophic backtracking. The pattern reaches us from a model, and a
40
- * waiting tool applies it repeatedly to a growing buffer, so an exponential
41
- * pattern would pin a core for the whole deadline.
41
+ * waiting tool applies it repeatedly to a growing buffer.
42
+ *
43
+ * The screen is @crewhaus/tool-safety's `compileUserRegex`, the one every
44
+ * tool shares (C079). 0.7.0 kept a scanner here that counted only `*` and
45
+ * `+` inside a group, so `(\w{1,})*$`, `(\w{1,64})*$` and `(\w\w?)*$`
46
+ * passed it and JavaScriptCore then gave up on them — a false "no match"
47
+ * after pinning the event loop. The shared screen models `{n,m}` and `?`
48
+ * too, accepts a repetition only when each pass ends in exactly one place,
49
+ * and is fuzzed; it also admits safe alternations the old scanner refused
50
+ * wholesale, such as `(a|b)*`. WaitForOutput still runs the match in the
51
+ * regex worker under a deadline: the screen is the second layer, not the
52
+ * first.
42
53
  */
43
54
  export function compileSafePattern(source, flags) {
44
- if (source.length > 1_000) {
45
- return { ok: false, message: `pattern is ${source.length} characters, over the 1000 limit` };
46
- }
47
- const risk = backtrackingRisk(source);
48
- if (risk === "nested-quantifier") {
49
- return {
50
- ok: false,
51
- message: "pattern nests one quantifier inside another, which can backtrack exponentially",
52
- };
53
- }
54
- if (risk === "quantified-alternation") {
55
- return {
56
- ok: false,
57
- message: "pattern quantifies a group containing an alternation, e.g. (a|a)*, which can backtrack exponentially — quantify a character class instead",
58
- };
59
- }
60
- try {
61
- return { ok: true, regex: new RegExp(source, flags) };
62
- }
63
- catch (err) {
64
- return { ok: false, message: `invalid regex: ${err.message}` };
65
- }
66
- }
67
- function backtrackingRisk(source) {
68
- let depth = 0;
69
- let nested = false;
70
- let quantifiedAlternation = false;
71
- const quantifiedAt = [];
72
- const alternationAt = [];
73
- for (let i = 0; i < source.length; i++) {
74
- const ch = source[i];
75
- if (ch === "\\") {
76
- i++;
77
- continue;
78
- }
79
- if (ch === "[") {
80
- while (i < source.length && source[i] !== "]") {
81
- if (source[i] === "\\")
82
- i++;
83
- i++;
84
- }
85
- continue;
86
- }
87
- if (ch === "(") {
88
- depth++;
89
- quantifiedAt[depth] = false;
90
- alternationAt[depth] = false;
91
- continue;
92
- }
93
- if (ch === "|") {
94
- if (depth > 0)
95
- alternationAt[depth] = true;
96
- continue;
97
- }
98
- if (ch === ")") {
99
- const next = source[i + 1];
100
- if (next === "*" || next === "+" || next === "{" || next === "?") {
101
- // This group is itself quantified. `?` cannot repeat, so it only
102
- // carries an inner quantifier outward; `*`, `+` and `{n,}` repeat,
103
- // which is what turns an overlapping alternation catastrophic.
104
- if (quantifiedAt[depth] === true && next !== "?")
105
- nested = true;
106
- if (alternationAt[depth] === true && next !== "?")
107
- quantifiedAlternation = true;
108
- if (depth > 1)
109
- quantifiedAt[depth - 1] = true;
110
- }
111
- depth = Math.max(0, depth - 1);
112
- continue;
113
- }
114
- if (ch === "*" || ch === "+") {
115
- if (depth > 0)
116
- quantifiedAt[depth] = true;
117
- }
55
+ const compiled = compileUserRegex(source, flags);
56
+ if (compiled.ok)
57
+ return compiled;
58
+ switch (compiled.code) {
59
+ case "nested-quantifier":
60
+ return {
61
+ ok: false,
62
+ message: `pattern nests one quantifier inside another, which can backtrack exponentially: ${compiled.reason}`,
63
+ };
64
+ case "overlapping-alternation":
65
+ return {
66
+ ok: false,
67
+ message: `pattern repeats an alternation whose branches can match the same text, e.g. (a|a)*, which can backtrack exponentially: ${compiled.reason}`,
68
+ };
69
+ case "invalid-syntax":
70
+ return { ok: false, message: `invalid regex: ${compiled.reason}` };
71
+ default:
72
+ return { ok: false, message: compiled.reason };
118
73
  }
119
- if (nested)
120
- return "nested-quantifier";
121
- if (quantifiedAlternation)
122
- return "quantified-alternation";
123
- return "none";
124
74
  }
@@ -0,0 +1,17 @@
1
+ /** The platform CommandExists resolves for. */
2
+ export declare function hostPlatform(): NodeJS.Platform;
3
+ /** Test seam: pretend to be another platform; `undefined` restores the real one. */
4
+ export declare function _setPlatform(platform: NodeJS.Platform | undefined): void;
5
+ /** Windows' default when PATHEXT is unset. */
6
+ export declare const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
7
+ export type PathSearch = {
8
+ /** The first match, or null. */
9
+ readonly path: string | null;
10
+ /** How many PATH entries were searched. */
11
+ readonly searchedDirs: number;
12
+ };
13
+ export declare function searchPath(name: string, options: {
14
+ readonly pathValue: string;
15
+ readonly platform: NodeJS.Platform;
16
+ readonly pathext?: string | undefined;
17
+ }): PathSearch;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Where a bare program name resolves on PATH — the question CommandExists
3
+ * answers — by the platform's own rule (C125).
4
+ *
5
+ * POSIX: each PATH directory in order, the first regular file with the name
6
+ * that is executable. Windows: an installed program is `git.exe` or
7
+ * `npm.cmd`, and the shell and Bun.spawn both find it by its bare name
8
+ * through PATHEXT. 0.7.0 probed only the bare name, so on Windows every
9
+ * program was "not found" while RunCommand could run it. Here, on win32,
10
+ * PATH is split on `;` (a quoted entry unquoted), and each directory is
11
+ * tried in order for the name as given when its extension is already one of
12
+ * PATHEXT's, then for the name plus each PATHEXT extension in PATHEXT's
13
+ * order. Existence and being a regular file is enough there: Windows has no
14
+ * execute bit to check.
15
+ *
16
+ * The platform is a parameter, read through `hostPlatform()` (with a test
17
+ * seam) so the Windows rule is exercised on a Linux or macOS CI runner.
18
+ */
19
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
20
+ import * as path from "node:path";
21
+ let platformOverride;
22
+ /** The platform CommandExists resolves for. */
23
+ export function hostPlatform() {
24
+ return platformOverride ?? process.platform;
25
+ }
26
+ /** Test seam: pretend to be another platform; `undefined` restores the real one. */
27
+ export function _setPlatform(platform) {
28
+ platformOverride = platform;
29
+ }
30
+ /** Windows' default when PATHEXT is unset. */
31
+ export const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
32
+ export function searchPath(name, options) {
33
+ const windows = options.platform === "win32";
34
+ const dirs = options.pathValue
35
+ .split(windows ? ";" : ":")
36
+ .map((d) => (windows ? d.trim().replace(/^"(.*)"$/, "$1") : d))
37
+ .filter((d) => d !== "");
38
+ const candidates = windows ? windowsCandidates(name, options.pathext) : [name];
39
+ for (const dir of dirs) {
40
+ for (const candidate of candidates) {
41
+ // The HOST join: on a real Windows host this is path.win32 already.
42
+ const full = path.join(dir, candidate);
43
+ if (isProgram(full, windows))
44
+ return { path: full, searchedDirs: dirs.length };
45
+ }
46
+ }
47
+ return { path: null, searchedDirs: dirs.length };
48
+ }
49
+ /** The spellings Windows would try for `name`, in order. */
50
+ function windowsCandidates(name, pathext) {
51
+ const exts = (pathext === undefined || pathext.trim() === "" ? DEFAULT_PATHEXT : pathext)
52
+ .split(";")
53
+ .map((e) => e.trim())
54
+ .filter((e) => e.startsWith(".") && e.length > 1);
55
+ const lowered = exts.map((e) => e.toLowerCase());
56
+ const out = [];
57
+ const own = path.extname(name).toLowerCase();
58
+ if (own !== "" && lowered.includes(own))
59
+ out.push(name);
60
+ for (const [i, ext] of exts.entries()) {
61
+ // Both spellings: Windows matches case-insensitively, a case-sensitive
62
+ // filesystem (a test on Linux, a WSL mount) does not.
63
+ out.push(`${name}${lowered[i]}`);
64
+ if (ext !== lowered[i])
65
+ out.push(`${name}${ext}`);
66
+ }
67
+ return [...new Set(out)];
68
+ }
69
+ function isProgram(full, windows) {
70
+ try {
71
+ if (!statSync(full).isFile())
72
+ return false;
73
+ if (!windows)
74
+ accessSync(full, fsConstants.X_OK);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
@@ -30,6 +30,16 @@ export type BgProc = {
30
30
  stdoutReturned: number;
31
31
  stderrReturned: number;
32
32
  status: BgStatus;
33
+ /**
34
+ * Each pipe has reached its end, so `stdout` (or `stderr`) holds all the
35
+ * process will ever print there. This can lag `status`: the exit and a
36
+ * pipe's last bytes reach the parent as separate events, and a process it
37
+ * started that shares a pipe can keep it open after it is gone.
38
+ */
39
+ stdoutEnded: boolean;
40
+ stderrEnded: boolean;
41
+ /** Both pipes have reached their end. */
42
+ outputEnded: boolean;
33
43
  exitCode: number | null;
34
44
  readonly startedAt: number;
35
45
  exitedAt: number | null;
@@ -55,6 +65,35 @@ export declare function getProc(id: string): BgProc | undefined;
55
65
  export declare function listProcs(): BgProc[];
56
66
  export declare function reapProc(id: string): boolean;
57
67
  export declare function markKilled(bg: BgProc): void;
68
+ /** The output a watch reads: one stream, or both. */
69
+ export type WatchedStream = "stdout" | "stderr" | "both";
70
+ /**
71
+ * Whether a process has finished as far as a watch of `stream` goes: the
72
+ * next look at that output is the last one worth taking.
73
+ *
74
+ * An exited process has finished once the watched pipe has ended too. The
75
+ * exit can reach the parent before the line printed just ahead of it, and a
76
+ * process it started can still hold the pipe and print. Only the watched
77
+ * pipe counts: one that holds just the other pipe cannot print on this one.
78
+ *
79
+ * A stopped one has finished once its own exit has been seen (`exitCode` is
80
+ * set then, to a number even for a death by signal), whatever its pipes are
81
+ * doing. Until then it is still running, whatever ProcessStop asked of it.
82
+ * ProcessStop signals the process alone, so one it started (a script running
83
+ * a server without `exec`) can hold the pipes for as long as it runs, and a
84
+ * wait on them would run to its deadline. The cost is the one 0.7.0 had: a
85
+ * line a stopped process prints as it goes, such as a SIGTERM handler's last
86
+ * words, can reach the parent after its exit and be missed. Stopping the
87
+ * whole process group would let the pipes decide here too.
88
+ */
89
+ export declare function watchFinished(bg: Pick<BgProc, "status" | "exitCode" | "stdoutEnded" | "stderrEnded" | "outputEnded">, stream: WatchedStream): boolean;
58
90
  /** Test-only: kill every live process and empty the table, including the id
59
91
  * counter, so each test file starts from the same state. */
60
92
  export declare function __resetRegistryForTest(): void;
93
+ /**
94
+ * Test-only: {@link __resetRegistryForTest}, then wait (at most `timeoutMs`)
95
+ * until every process it knew of has exited. A test removes its workspace
96
+ * after this, and on Windows a directory that is a live process's cwd cannot
97
+ * be removed (EBUSY), while a kill there returns before the process is gone.
98
+ */
99
+ export declare function __stopAllForTest(timeoutMs?: number): Promise<void>;
package/dist/registry.js CHANGED
@@ -117,6 +117,9 @@ export function startBackground(argv, options) {
117
117
  stdoutReturned: 0,
118
118
  stderrReturned: 0,
119
119
  status: "running",
120
+ stdoutEnded: false,
121
+ stderrEnded: false,
122
+ outputEnded: false,
120
123
  exitCode: null,
121
124
  startedAt: Date.now(),
122
125
  exitedAt: null,
@@ -124,8 +127,16 @@ export function startBackground(argv, options) {
124
127
  };
125
128
  procs.set(id, bg);
126
129
  // Fire and forget: the tool returns while the process keeps running.
127
- void pump(spawned.stdout, (t) => append(bg, "stdout", t));
128
- void pump(spawned.stderr, (t) => append(bg, "stderr", t));
130
+ void Promise.all([
131
+ pump(spawned.stdout, (t) => append(bg, "stdout", t)).then(() => {
132
+ bg.stdoutEnded = true;
133
+ }),
134
+ pump(spawned.stderr, (t) => append(bg, "stderr", t)).then(() => {
135
+ bg.stderrEnded = true;
136
+ }),
137
+ ]).then(() => {
138
+ bg.outputEnded = true;
139
+ });
129
140
  void spawned.exited.then((code) => {
130
141
  if (bg.status === "running")
131
142
  bg.status = "exited";
@@ -155,6 +166,36 @@ export function markKilled(bg) {
155
166
  bg.exitedAt = Date.now();
156
167
  }
157
168
  }
169
+ /**
170
+ * Whether a process has finished as far as a watch of `stream` goes: the
171
+ * next look at that output is the last one worth taking.
172
+ *
173
+ * An exited process has finished once the watched pipe has ended too. The
174
+ * exit can reach the parent before the line printed just ahead of it, and a
175
+ * process it started can still hold the pipe and print. Only the watched
176
+ * pipe counts: one that holds just the other pipe cannot print on this one.
177
+ *
178
+ * A stopped one has finished once its own exit has been seen (`exitCode` is
179
+ * set then, to a number even for a death by signal), whatever its pipes are
180
+ * doing. Until then it is still running, whatever ProcessStop asked of it.
181
+ * ProcessStop signals the process alone, so one it started (a script running
182
+ * a server without `exec`) can hold the pipes for as long as it runs, and a
183
+ * wait on them would run to its deadline. The cost is the one 0.7.0 had: a
184
+ * line a stopped process prints as it goes, such as a SIGTERM handler's last
185
+ * words, can reach the parent after its exit and be missed. Stopping the
186
+ * whole process group would let the pipes decide here too.
187
+ */
188
+ export function watchFinished(bg, stream) {
189
+ if (bg.status === "running")
190
+ return false;
191
+ if (bg.status === "killed")
192
+ return bg.exitCode !== null;
193
+ if (stream === "stdout")
194
+ return bg.stdoutEnded;
195
+ if (stream === "stderr")
196
+ return bg.stderrEnded;
197
+ return bg.outputEnded;
198
+ }
158
199
  /** Test-only: kill every live process and empty the table, including the id
159
200
  * counter, so each test file starts from the same state. */
160
201
  export function __resetRegistryForTest() {
@@ -171,3 +212,26 @@ export function __resetRegistryForTest() {
171
212
  procs.clear();
172
213
  nextId = 1;
173
214
  }
215
+ /**
216
+ * Test-only: {@link __resetRegistryForTest}, then wait (at most `timeoutMs`)
217
+ * until every process it knew of has exited. A test removes its workspace
218
+ * after this, and on Windows a directory that is a live process's cwd cannot
219
+ * be removed (EBUSY), while a kill there returns before the process is gone.
220
+ */
221
+ export async function __stopAllForTest(timeoutMs = 5_000) {
222
+ const exits = [...procs.values()].map((bg) => bg.proc.exited);
223
+ __resetRegistryForTest();
224
+ let timer;
225
+ try {
226
+ await Promise.race([
227
+ Promise.all(exits),
228
+ new Promise((resolve) => {
229
+ timer = setTimeout(resolve, timeoutMs);
230
+ }),
231
+ ]);
232
+ }
233
+ finally {
234
+ if (timer !== undefined)
235
+ clearTimeout(timer);
236
+ }
237
+ }
@@ -20,6 +20,17 @@ export type SafePath = {
20
20
  readonly ok: false;
21
21
  readonly message: string;
22
22
  };
23
+ /**
24
+ * The longest path this resolves, in UTF-16 code units; a longer one is
25
+ * refused before it is walked. Nothing that long can be opened or created:
26
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
27
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
28
+ * UTF-16 one. The limit is twice that because the part of a path that exists
29
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
30
+ * own: no spelling of the place reaches it, not even one through a symlink
31
+ * that shortens the part that exists.
32
+ */
33
+ export declare const MAX_PATH_CHARS: number;
23
34
  /**
24
35
  * Is `candidate` the root itself, or something beneath it? Both arguments
25
36
  * must already be REAL paths — this is a string containment test, and it is
package/dist/safe-path.js CHANGED
@@ -1,5 +1,24 @@
1
1
  import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
2
2
  import * as path from "node:path";
3
+ /**
4
+ * The longest path this resolves, in UTF-16 code units; a longer one is
5
+ * refused before it is walked. Nothing that long can be opened or created:
6
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
7
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
8
+ * UTF-16 one. The limit is twice that because the part of a path that exists
9
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
10
+ * own: no spelling of the place reaches it, not even one through a symlink
11
+ * that shortens the part that exists.
12
+ */
13
+ export const MAX_PATH_CHARS = 2 * (process.platform === "darwin" ? 1_024 : process.platform === "win32" ? 32_767 : 4_096);
14
+ /** A path as a refusal quotes it: whole, unless it is past {@link MAX_PATH_CHARS}. */
15
+ function shownPath(given) {
16
+ return given.length > MAX_PATH_CHARS ? `${given.slice(0, 64)}…` : given;
17
+ }
18
+ /** Why a path past {@link MAX_PATH_CHARS} is refused: its length and the limit. */
19
+ function tooLong(abs) {
20
+ return `it is ${abs.length} characters once resolved, over the ${MAX_PATH_CHARS}-character limit: no path that long can be opened or created`;
21
+ }
3
22
  /**
4
23
  * Is `candidate` the root itself, or something beneath it? Both arguments
5
24
  * must already be REAL paths — this is a string containment test, and it is
@@ -47,15 +66,7 @@ function nameExists(p) {
47
66
  function resolveLocation(target, depth = 0) {
48
67
  if (depth > 40)
49
68
  throw new Error(`symlink chain at "${target}" is too long to resolve`);
50
- let probe = target;
51
- const tail = [];
52
- while (!nameExists(probe)) {
53
- tail.unshift(path.basename(probe));
54
- const parent = path.dirname(probe);
55
- if (parent === probe)
56
- break; // reached the filesystem root
57
- probe = parent;
58
- }
69
+ const { probe, tail } = deepestName(target);
59
70
  let probeReal;
60
71
  try {
61
72
  probeReal = realpathSync(probe);
@@ -80,7 +91,41 @@ function resolveLocation(target, depth = 0) {
80
91
  const base = realpathSync(path.dirname(probe));
81
92
  probeReal = resolveLocation(path.resolve(base, link), depth + 1);
82
93
  }
83
- return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
94
+ // One string, not one argument per name: Bun's `path.join` keeps only
95
+ // (argument count mod 65,536) of its arguments.
96
+ return tail.length > 0 ? path.join(probeReal, tail) : probeReal;
97
+ }
98
+ /**
99
+ * `target`, an absolute path, split where its deepest prefix that exists as
100
+ * a NAME ends: that prefix, and the names below it, which do not exist, as
101
+ * one string ("" when `target` itself exists). This is `path.dirname` and
102
+ * `path.basename` worked by index on `target` itself: handing them each
103
+ * shorter prefix in turn copied the rest of the path once per missing name,
104
+ * the square of a count the caller picks.
105
+ */
106
+ function deepestName(target) {
107
+ const top = path.parse(target).root.length;
108
+ const isSep = (i) => target[i] === "/" || target[i] === path.sep;
109
+ let probe = target;
110
+ let start = target.length;
111
+ let stop = -1;
112
+ while (!nameExists(probe)) {
113
+ // `path.basename(probe)` is `target.slice(start, end)`, and
114
+ // `path.dirname(probe)` is `target.slice(0, up)`.
115
+ let end = probe.length;
116
+ while (end > top && isSep(end - 1))
117
+ end--;
118
+ if (stop < 0)
119
+ stop = end;
120
+ start = end;
121
+ while (start > top && !isSep(start - 1))
122
+ start--;
123
+ const up = start > top ? start - 1 : top;
124
+ if (up === probe.length)
125
+ break; // reached the filesystem root
126
+ probe = target.slice(0, up);
127
+ }
128
+ return { probe, tail: stop < 0 ? "" : target.slice(start, stop) };
84
129
  }
85
130
  /**
86
131
  * Re-check, at the moment of use, that a path resolved earlier still lives
@@ -130,6 +175,13 @@ export function resolveSafe(toolName, rel, root = process.cwd()) {
130
175
  if (!isInsideRoot(abs, rootResolved)) {
131
176
  return refusal("it resolves outside the workspace root");
132
177
  }
178
+ // Longer than any path can be: refused before the walk below reads it.
179
+ if (abs.length > MAX_PATH_CHARS) {
180
+ return {
181
+ ok: false,
182
+ message: `[${toolName} error] refused path "${shownPath(rel)}": ${tooLong(abs)}.`,
183
+ };
184
+ }
133
185
  try {
134
186
  const rootReal = realpathSync(rootResolved);
135
187
  // The leaf may not exist yet (a wait can watch for a file's creation),
package/dist/spawn.d.ts CHANGED
@@ -1,3 +1,36 @@
1
+ /**
2
+ * The one place this package starts a child process.
3
+ *
4
+ * Everything that runs a command — RunCommand, RunPipeline, Retry and the
5
+ * background family — goes through here so a single set of rules holds:
6
+ *
7
+ * - argv is passed to the OS as an ARRAY. There is no shell anywhere in
8
+ * this package, so a filename with a space or a `$(...)` in it is an
9
+ * argument and never a command.
10
+ * - stdin is `ignore` unless the caller supplied it, so a command that
11
+ * reads stdin gets EOF immediately instead of hanging on a terminal
12
+ * that is not there.
13
+ * - every run has a deadline: SIGTERM on expiry, SIGKILL after a short
14
+ * grace period for a child that ignores the first signal. The child
15
+ * leads its own process group and the deadline (or an abort) signals the
16
+ * whole group, so a `cmd &` it started cannot outlive the timeout.
17
+ * - the child runs in a SESSION of its own (spawnBounded's detached spawn,
18
+ * setsid), so it has no controlling terminal. That is deliberate, and a
19
+ * change from 0.7.0: a model-run command cannot read the operator's
20
+ * keystrokes from /dev/tty (the answers typed into the harness's own
21
+ * approval prompt), cannot push input into that terminal (TIOCSTI), and
22
+ * a `sudo`/`ssh`/`gpg` prompt fails at once instead of waiting on a
23
+ * terminal the model cannot see. Bun has no "own process group but keep
24
+ * the terminal" spawn, and the group is what makes the deadline real.
25
+ * The background family (ProcessStart) keeps 0.7.0's spawn.
26
+ * - both streams are capped AS THEY ARE READ (C078). The foreground run is
27
+ * @crewhaus/tool-safety's `spawnBounded`: memory is bounded by the cap,
28
+ * not by what the child prints, and the reader keeps draining past the
29
+ * cap so a child never blocks on a full pipe.
30
+ * - output a grandchild still holds open after the child exits is read for
31
+ * a bounded grace, then reading stops and the stream is cancelled: the
32
+ * result keeps what arrived and says `outputIncomplete`, never "".
33
+ */
1
34
  export type RunOutcome = {
2
35
  readonly argv: readonly string[];
3
36
  readonly exitCode: number;
@@ -14,6 +47,12 @@ export type RunOutcome = {
14
47
  * the result is what had been captured by then, and `exitCode` is unknown.
15
48
  */
16
49
  readonly abandoned?: boolean;
50
+ /**
51
+ * Set when reading stopped before a stream ended: the child exited but a
52
+ * process it started still held its output open past the drain grace. The
53
+ * text is what arrived by then — possibly all of it, possibly not.
54
+ */
55
+ readonly outputIncomplete?: boolean;
17
56
  };
18
57
  export type RunOptions = {
19
58
  readonly cwd: string;