@dungle-scrubs/harness-cli-normalizer 0.1.1

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 (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/dist/execution/channel.d.ts +33 -0
  4. package/dist/execution/channel.d.ts.map +1 -0
  5. package/dist/execution/channel.js +86 -0
  6. package/dist/execution/channel.js.map +1 -0
  7. package/dist/execution/decode.d.ts +16 -0
  8. package/dist/execution/decode.d.ts.map +1 -0
  9. package/dist/execution/decode.js +60 -0
  10. package/dist/execution/decode.js.map +1 -0
  11. package/dist/execution/deps.d.ts +53 -0
  12. package/dist/execution/deps.d.ts.map +1 -0
  13. package/dist/execution/deps.js +8 -0
  14. package/dist/execution/deps.js.map +1 -0
  15. package/dist/execution/events.d.ts +54 -0
  16. package/dist/execution/events.d.ts.map +1 -0
  17. package/dist/execution/events.js +2 -0
  18. package/dist/execution/events.js.map +1 -0
  19. package/dist/execution/index.d.ts +19 -0
  20. package/dist/execution/index.d.ts.map +1 -0
  21. package/dist/execution/index.js +19 -0
  22. package/dist/execution/index.js.map +1 -0
  23. package/dist/execution/lines.d.ts +21 -0
  24. package/dist/execution/lines.d.ts.map +1 -0
  25. package/dist/execution/lines.js +70 -0
  26. package/dist/execution/lines.js.map +1 -0
  27. package/dist/execution/node-deps.d.ts +13 -0
  28. package/dist/execution/node-deps.d.ts.map +1 -0
  29. package/dist/execution/node-deps.js +164 -0
  30. package/dist/execution/node-deps.js.map +1 -0
  31. package/dist/execution/open-session.d.ts +31 -0
  32. package/dist/execution/open-session.d.ts.map +1 -0
  33. package/dist/execution/open-session.js +360 -0
  34. package/dist/execution/open-session.js.map +1 -0
  35. package/dist/execution/stream-turn.d.ts +43 -0
  36. package/dist/execution/stream-turn.d.ts.map +1 -0
  37. package/dist/execution/stream-turn.js +283 -0
  38. package/dist/execution/stream-turn.js.map +1 -0
  39. package/dist/index.d.ts +8 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +8 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/interpretation/argv.d.ts +35 -0
  44. package/dist/interpretation/argv.d.ts.map +1 -0
  45. package/dist/interpretation/argv.js +147 -0
  46. package/dist/interpretation/argv.js.map +1 -0
  47. package/dist/interpretation/capabilities.d.ts +19 -0
  48. package/dist/interpretation/capabilities.d.ts.map +1 -0
  49. package/dist/interpretation/capabilities.js +25 -0
  50. package/dist/interpretation/capabilities.js.map +1 -0
  51. package/dist/interpretation/content.d.ts +33 -0
  52. package/dist/interpretation/content.d.ts.map +1 -0
  53. package/dist/interpretation/content.js +169 -0
  54. package/dist/interpretation/content.js.map +1 -0
  55. package/dist/interpretation/context.d.ts +17 -0
  56. package/dist/interpretation/context.d.ts.map +1 -0
  57. package/dist/interpretation/context.js +16 -0
  58. package/dist/interpretation/context.js.map +1 -0
  59. package/dist/interpretation/dimensions.d.ts +13 -0
  60. package/dist/interpretation/dimensions.d.ts.map +1 -0
  61. package/dist/interpretation/dimensions.js +6 -0
  62. package/dist/interpretation/dimensions.js.map +1 -0
  63. package/dist/interpretation/identity.d.ts +32 -0
  64. package/dist/interpretation/identity.d.ts.map +1 -0
  65. package/dist/interpretation/identity.js +44 -0
  66. package/dist/interpretation/identity.js.map +1 -0
  67. package/dist/interpretation/index.d.ts +23 -0
  68. package/dist/interpretation/index.d.ts.map +1 -0
  69. package/dist/interpretation/index.js +23 -0
  70. package/dist/interpretation/index.js.map +1 -0
  71. package/dist/interpretation/limits.d.ts +20 -0
  72. package/dist/interpretation/limits.d.ts.map +1 -0
  73. package/dist/interpretation/limits.js +35 -0
  74. package/dist/interpretation/limits.js.map +1 -0
  75. package/dist/interpretation/parse-resume.d.ts +28 -0
  76. package/dist/interpretation/parse-resume.d.ts.map +1 -0
  77. package/dist/interpretation/parse-resume.js +91 -0
  78. package/dist/interpretation/parse-resume.js.map +1 -0
  79. package/dist/interpretation/presence.d.ts +16 -0
  80. package/dist/interpretation/presence.d.ts.map +1 -0
  81. package/dist/interpretation/presence.js +32 -0
  82. package/dist/interpretation/presence.js.map +1 -0
  83. package/dist/interpretation/resume-last.d.ts +32 -0
  84. package/dist/interpretation/resume-last.d.ts.map +1 -0
  85. package/dist/interpretation/resume-last.js +70 -0
  86. package/dist/interpretation/resume-last.js.map +1 -0
  87. package/dist/interpretation/session-id.d.ts +22 -0
  88. package/dist/interpretation/session-id.d.ts.map +1 -0
  89. package/dist/interpretation/session-id.js +29 -0
  90. package/dist/interpretation/session-id.js.map +1 -0
  91. package/dist/interpretation/session-input.d.ts +13 -0
  92. package/dist/interpretation/session-input.d.ts.map +1 -0
  93. package/dist/interpretation/session-input.js +32 -0
  94. package/dist/interpretation/session-input.js.map +1 -0
  95. package/dist/interpretation/shape.d.ts +17 -0
  96. package/dist/interpretation/shape.d.ts.map +1 -0
  97. package/dist/interpretation/shape.js +54 -0
  98. package/dist/interpretation/shape.js.map +1 -0
  99. package/dist/interpretation/store.d.ts +16 -0
  100. package/dist/interpretation/store.d.ts.map +1 -0
  101. package/dist/interpretation/store.js +27 -0
  102. package/dist/interpretation/store.js.map +1 -0
  103. package/dist/interpretation/versions.d.ts +16 -0
  104. package/dist/interpretation/versions.d.ts.map +1 -0
  105. package/dist/interpretation/versions.js +44 -0
  106. package/dist/interpretation/versions.js.map +1 -0
  107. package/dist/interpretation/vocabulary.d.ts +29 -0
  108. package/dist/interpretation/vocabulary.d.ts.map +1 -0
  109. package/dist/interpretation/vocabulary.js +55 -0
  110. package/dist/interpretation/vocabulary.js.map +1 -0
  111. package/dist/knowledge/claude-code.d.ts +8 -0
  112. package/dist/knowledge/claude-code.d.ts.map +1 -0
  113. package/dist/knowledge/claude-code.js +132 -0
  114. package/dist/knowledge/claude-code.js.map +1 -0
  115. package/dist/knowledge/codex.d.ts +9 -0
  116. package/dist/knowledge/codex.d.ts.map +1 -0
  117. package/dist/knowledge/codex.js +106 -0
  118. package/dist/knowledge/codex.js.map +1 -0
  119. package/dist/knowledge/descriptor.d.ts +211 -0
  120. package/dist/knowledge/descriptor.d.ts.map +1 -0
  121. package/dist/knowledge/descriptor.js +20 -0
  122. package/dist/knowledge/descriptor.js.map +1 -0
  123. package/dist/knowledge/index.d.ts +19 -0
  124. package/dist/knowledge/index.d.ts.map +1 -0
  125. package/dist/knowledge/index.js +19 -0
  126. package/dist/knowledge/index.js.map +1 -0
  127. package/dist/knowledge/matchers.d.ts +10 -0
  128. package/dist/knowledge/matchers.d.ts.map +1 -0
  129. package/dist/knowledge/matchers.js +11 -0
  130. package/dist/knowledge/matchers.js.map +1 -0
  131. package/dist/knowledge/muse.d.ts +12 -0
  132. package/dist/knowledge/muse.d.ts.map +1 -0
  133. package/dist/knowledge/muse.js +94 -0
  134. package/dist/knowledge/muse.js.map +1 -0
  135. package/dist/knowledge/overrides.d.ts +11 -0
  136. package/dist/knowledge/overrides.d.ts.map +1 -0
  137. package/dist/knowledge/overrides.js +166 -0
  138. package/dist/knowledge/overrides.js.map +1 -0
  139. package/dist/knowledge/pi.d.ts +11 -0
  140. package/dist/knowledge/pi.d.ts.map +1 -0
  141. package/dist/knowledge/pi.js +97 -0
  142. package/dist/knowledge/pi.js.map +1 -0
  143. package/package.json +69 -0
  144. package/src/execution/channel.ts +86 -0
  145. package/src/execution/decode.ts +79 -0
  146. package/src/execution/deps.ts +59 -0
  147. package/src/execution/events.ts +37 -0
  148. package/src/execution/index.ts +18 -0
  149. package/src/execution/lines.ts +71 -0
  150. package/src/execution/node-deps.ts +179 -0
  151. package/src/execution/open-session.ts +392 -0
  152. package/src/execution/stream-turn.ts +310 -0
  153. package/src/index.ts +7 -0
  154. package/src/interpretation/argv.ts +188 -0
  155. package/src/interpretation/capabilities.ts +51 -0
  156. package/src/interpretation/content.ts +191 -0
  157. package/src/interpretation/context.ts +28 -0
  158. package/src/interpretation/dimensions.ts +20 -0
  159. package/src/interpretation/identity.ts +81 -0
  160. package/src/interpretation/index.ts +22 -0
  161. package/src/interpretation/limits.ts +63 -0
  162. package/src/interpretation/parse-resume.ts +117 -0
  163. package/src/interpretation/presence.ts +49 -0
  164. package/src/interpretation/resume-last.ts +93 -0
  165. package/src/interpretation/session-id.ts +35 -0
  166. package/src/interpretation/session-input.ts +45 -0
  167. package/src/interpretation/shape.ts +55 -0
  168. package/src/interpretation/store.ts +43 -0
  169. package/src/interpretation/versions.ts +43 -0
  170. package/src/interpretation/vocabulary.ts +74 -0
  171. package/src/knowledge/claude-code.ts +132 -0
  172. package/src/knowledge/codex.ts +106 -0
  173. package/src/knowledge/descriptor.ts +217 -0
  174. package/src/knowledge/index.ts +23 -0
  175. package/src/knowledge/matchers.ts +19 -0
  176. package/src/knowledge/muse.ts +94 -0
  177. package/src/knowledge/overrides.ts +182 -0
  178. package/src/knowledge/pi.ts +97 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Injected runtime primitives (D-005): the execution layer never imports
3
+ * child_process, never calls process.kill, and never reads a wall clock -
4
+ * spawn, clock, and signalling arrive as data so Node/Bun is a real
5
+ * portability boundary and tests run against fakes deterministically.
6
+ */
7
+
8
+ export interface SpawnedProcess {
9
+ readonly stdout: AsyncIterable<string | Uint8Array>;
10
+ readonly stderr: AsyncIterable<string | Uint8Array>;
11
+ /** Idempotently terminate stdout and stderr reads without signalling the
12
+ * child. An adapter MUST preserve chunks already buffered when disposal is
13
+ * requested, then settle the first read beyond that finite snapshot.
14
+ * Terminal cleanup owns process signalling separately. */
15
+ disposeOutput(): void;
16
+ /** Resolves with the exit code, or null when the process died to a
17
+ * signal without one. */
18
+ readonly exited: Promise<number | null>;
19
+ /** Present when the spawner opened a writable stdin (session mode needs
20
+ * it; one-shot turns close or inherit stdin instead). */
21
+ readonly stdin?: {
22
+ write(data: string): void;
23
+ end(): void;
24
+ };
25
+ }
26
+
27
+ export interface SpawnOptions {
28
+ readonly cwd?: string;
29
+ /** `pipe` opens a writable stdin (sessions require it); `inherit` hands
30
+ * the child the parent's fd 0; `close` gives it nothing (pi's rule). */
31
+ readonly stdin: "inherit" | "close" | "pipe";
32
+ }
33
+
34
+ export type TimerHandle = number;
35
+
36
+ export interface Clock {
37
+ now(): number;
38
+ setTimeout(fn: () => void, ms: number): TimerHandle;
39
+ clearTimeout(handle: TimerHandle): void;
40
+ }
41
+
42
+ export type SignalName = "SIGTERM" | "SIGKILL";
43
+
44
+ /** One structured line per boundary transition - spawn, exit, stall,
45
+ * redacted argv. Always-on baseline evidence; the host decides where it
46
+ * goes. */
47
+ export type BoundaryLog = (event: Record<string, unknown>) => void;
48
+
49
+ export interface RunnerDeps {
50
+ readonly spawn: (argv: readonly string[], opts: SpawnOptions) => SpawnedProcess;
51
+ readonly clock: Clock;
52
+ readonly signal: (proc: SpawnedProcess, sig: SignalName) => void;
53
+ /** Stall watchdog budget; armed ONLY for none-granularity invocations. */
54
+ readonly stallMs?: number;
55
+ readonly log?: BoundaryLog;
56
+ /** Host-minted correlation id; without it the runner falls back to a
57
+ * per-process monotonic counter (collides across processes). */
58
+ readonly turnId?: string;
59
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * HarnessEvent: the runner's output vocabulary (PLAN.md Part 0). The
3
+ * normalizer owns these events and the parsing that produces them; it does
4
+ * NOT own the chat protocol - lucid maps events into frames downstream.
5
+ * Only `done` is terminal. A turn may emit MULTIPLE `error` events before
6
+ * it (a decoded mid-stream error, then a crash-exit stderr tail, etc.), so
7
+ * a consumer treats `error` as informational and waits for `done`.
8
+ *
9
+ * Event classes are load-bearing for backpressure: token/progress/context
10
+ * are droppable (coalescible, latest-wins); the rest are lossless. The
11
+ * split is DECLARED here because the normalizer emits the events; the
12
+ * coalescing policy that consumes it lives in the chat layer (lucid-v2).
13
+ * Under token granularity a turn's text arrives twice by design: as token
14
+ * deltas AND as the whole trailing message event - render one, never
15
+ * concatenate both.
16
+ */
17
+ import type { CapabilityResult } from "../interpretation/capabilities.js";
18
+
19
+ export type ExitCause = "clean" | "limit" | "crash" | "stall" | "killed";
20
+
21
+ export type HarnessEvent =
22
+ | {
23
+ readonly kind: "identity";
24
+ readonly sessionId: string;
25
+ readonly authority: "caller-assigned" | "harness-minted";
26
+ readonly capabilities: CapabilityResult;
27
+ }
28
+ | { readonly kind: "token"; readonly text: string }
29
+ | { readonly kind: "message"; readonly role: string; readonly text: string }
30
+ | { readonly kind: "progress"; readonly label: string }
31
+ | { readonly kind: "tool"; readonly name: string; readonly input?: unknown }
32
+ | { readonly kind: "context"; readonly usedPct: number }
33
+ | { readonly kind: "limit"; readonly code: string; readonly message: string }
34
+ | { readonly kind: "error"; readonly message: string }
35
+ | { readonly kind: "done"; readonly exitCode: number | null; readonly cause: ExitCause };
36
+
37
+ export const DROPPABLE_KINDS = new Set(["token", "progress", "context"]);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Execution layer: owns the process lifecycle for a harness session.
3
+ *
4
+ * It exposes openSession() and streamTurn(), which drive a CLI process via
5
+ * injected {spawn, clock, signal} primitives and emit HarnessEvent values.
6
+ * This module knows nothing about any chat protocol. Because it must run
7
+ * identically on Node and Bun, it never imports child_process and never calls
8
+ * process.kill directly outside the node-deps adapter - all process I/O and
9
+ * signalling flows through the injected primitives.
10
+ */
11
+ export { AsyncChannel } from "./channel.js";
12
+ export * from "./decode.js";
13
+ export * from "./deps.js";
14
+ export * from "./events.js";
15
+ export { LINE_MAX, LineBuffer } from "./lines.js";
16
+ export { nodeRunnerDeps } from "./node-deps.js";
17
+ export * from "./open-session.js";
18
+ export * from "./stream-turn.js";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Line assembly for process streams: chunks arrive torn at arbitrary BYTE
3
+ * boundaries, so the byte->string step must stream (a multibyte character
4
+ * split across chunks decodes whole, never as replacement bytes), and
5
+ * complete lines are only ever cut at '\n'. The pending partial is scanned
6
+ * incrementally (never re-split from the start) and bounded: a payload
7
+ * must not become parseable - or resident - by luck of where the pipe
8
+ * split it, so an over-long line flips to discard until its newline.
9
+ */
10
+
11
+ export const LINE_MAX = 65_536;
12
+
13
+ export class LineBuffer {
14
+ // Per-instance and stateful ({stream:true}): a shared decoder would carry
15
+ // partial-sequence state across two streams and corrupt both.
16
+ private readonly decoder = new TextDecoder();
17
+ private pending = "";
18
+ private scanFrom = 0;
19
+ private discarding = false;
20
+
21
+ push(chunk: string | Uint8Array): string[] {
22
+ const text = typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true });
23
+ return this.ingest(text);
24
+ }
25
+
26
+ private ingest(text: string): string[] {
27
+ this.pending += text;
28
+ const lines: string[] = [];
29
+ while (true) {
30
+ const at = this.pending.indexOf("\n", this.scanFrom);
31
+ if (at === -1) break;
32
+ const line = this.pending.slice(0, at);
33
+ this.pending = this.pending.slice(at + 1);
34
+ this.scanFrom = 0;
35
+ if (this.discarding) {
36
+ // The truncated head of an over-long line ends here; drop it whole.
37
+ this.discarding = false;
38
+ continue;
39
+ }
40
+ // The bound applies to COMPLETE lines too - an oversized line whose
41
+ // newline arrived in the same chunk must not bypass it.
42
+ if (line.length <= LINE_MAX && !isBlank(line)) lines.push(line);
43
+ }
44
+ this.scanFrom = this.pending.length;
45
+ if (this.pending.length > LINE_MAX) {
46
+ this.pending = "";
47
+ this.scanFrom = 0;
48
+ this.discarding = true;
49
+ }
50
+ return lines;
51
+ }
52
+
53
+ /** The final partial line at stream close, if any. */
54
+ flush(): string | null {
55
+ const tail = this.decoder.decode();
56
+ if (tail !== "") this.pending += tail;
57
+ const rest = this.discarding || isBlank(this.pending) ? null : this.pending;
58
+ this.pending = "";
59
+ this.scanFrom = 0;
60
+ this.discarding = false;
61
+ return rest;
62
+ }
63
+ }
64
+
65
+ const isBlank = (line: string): boolean => {
66
+ for (let i = 0; i < line.length; i++) {
67
+ const c = line.charCodeAt(i);
68
+ if (c !== 32 && c !== 9 && c !== 13) return false;
69
+ }
70
+ return true;
71
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The real runtime adapter for RunnerDeps: node:child_process spawn (Bun
3
+ * implements the same module, so this is the Node AND Bun adapter), wall
4
+ * clock, and process signalling. This is the ONE module where the injected
5
+ * primitives touch the operating system - everything above it stays pure
6
+ * and fake-driven. Kills go to the exact child handle this adapter
7
+ * spawned, never to a pid pattern.
8
+ */
9
+ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
10
+ import type { Readable } from "node:stream";
11
+ import type { Clock, RunnerDeps, SpawnedProcess, SpawnOptions } from "./deps.js";
12
+
13
+ const children = new WeakMap<SpawnedProcess, ChildProcess>();
14
+
15
+ const emptyStream = async function* (): AsyncIterable<Uint8Array> {};
16
+
17
+ type SpawnOutcome = "spawned" | "failed";
18
+
19
+ /** Normalize only errors created by this adapter's own terminal operations.
20
+ * Identity comparison keeps an unrelated read failure visible even when it
21
+ * settles after output disposal has started. */
22
+ const normalizeOutputStream = async function* (
23
+ source: AsyncIterable<Uint8Array>,
24
+ disposalCause: Error,
25
+ spawnOutcome: Promise<SpawnOutcome>,
26
+ ): AsyncIterable<Uint8Array> {
27
+ try {
28
+ yield* source;
29
+ } catch (cause) {
30
+ if (cause === disposalCause) return;
31
+ if (
32
+ (cause as NodeJS.ErrnoException)?.code === "ERR_STREAM_PREMATURE_CLOSE" &&
33
+ (await spawnOutcome) === "failed"
34
+ ) {
35
+ return;
36
+ }
37
+ throw cause;
38
+ }
39
+ };
40
+
41
+ /** Request terminal disposal without discarding chunks already buffered by
42
+ * the runtime. Once the consumer reaches the first pending read, destruction
43
+ * settles that read with this adapter's unique disposal cause. */
44
+ export const disposableOutputStream = (
45
+ source: Readable,
46
+ disposalCause: Error,
47
+ spawnOutcome: Promise<SpawnOutcome>,
48
+ ): { readonly stream: AsyncIterable<Uint8Array>; dispose(): void } => {
49
+ let disposalRequested = false;
50
+ let disposalDrainBudget = 0;
51
+ let readPending = false;
52
+
53
+ const destroyPendingRead = (): void => {
54
+ if (disposalRequested && readPending && disposalDrainBudget === 0 && !source.destroyed) {
55
+ source.destroy(disposalCause);
56
+ }
57
+ };
58
+ const drainBeforeDisposal = async function* (): AsyncIterable<Uint8Array> {
59
+ const iterator = source[Symbol.asyncIterator]();
60
+ while (true) {
61
+ readPending = true;
62
+ if (disposalRequested && disposalDrainBudget === 0 && !source.destroyed) {
63
+ source.destroy(disposalCause);
64
+ }
65
+ let next: IteratorResult<unknown>;
66
+ try {
67
+ next = await iterator.next();
68
+ } finally {
69
+ readPending = false;
70
+ }
71
+ if (next.done) return;
72
+ if (disposalRequested) {
73
+ const bytes = next.value as { readonly byteLength?: number };
74
+ disposalDrainBudget = Math.max(0, disposalDrainBudget - (bytes.byteLength ?? 0));
75
+ }
76
+ yield next.value as Uint8Array;
77
+ }
78
+ };
79
+
80
+ return {
81
+ stream: normalizeOutputStream(drainBeforeDisposal(), disposalCause, spawnOutcome),
82
+ dispose: (): void => {
83
+ if (disposalRequested) return;
84
+ disposalRequested = true;
85
+ disposalDrainBudget = source.readableLength;
86
+ destroyPendingRead();
87
+ },
88
+ };
89
+ };
90
+
91
+ const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess => {
92
+ const [bin, ...args] = argv;
93
+ if (bin === undefined) throw new Error("empty argv");
94
+ const child = nodeSpawn(bin, args, {
95
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
96
+ stdio: [
97
+ opts.stdin === "pipe" ? "pipe" : opts.stdin === "close" ? "ignore" : "inherit",
98
+ "pipe",
99
+ "pipe",
100
+ ],
101
+ });
102
+ let outputDisposed = false;
103
+ const stdoutDisposalCause = new Error("stdout disposed by runner");
104
+ const stderrDisposalCause = new Error("stderr disposed by runner");
105
+ let spawnError: Error | null = null;
106
+ const spawnOutcome = new Promise<SpawnOutcome>((resolve) => {
107
+ child.once("spawn", () => resolve("spawned"));
108
+ child.once("error", () => resolve("failed"));
109
+ });
110
+ const stdoutOutput =
111
+ child.stdout === null
112
+ ? null
113
+ : disposableOutputStream(child.stdout, stdoutDisposalCause, spawnOutcome);
114
+ const stderrOutput =
115
+ child.stderr === null
116
+ ? null
117
+ : disposableOutputStream(child.stderr, stderrDisposalCause, spawnOutcome);
118
+ // exited resolves via 'exit' (process died - fires even while a grandchild
119
+ // holds the pipes open, unlike 'close') or 'error' (ENOENT etc., which
120
+ // node reports async, not as a sync throw -> map to 127 crash). The error
121
+ // handler sets spawnError before it resolves exited.
122
+ const exited = new Promise<number | null>((resolve) => {
123
+ child.on("error", (err) => {
124
+ spawnError = err;
125
+ resolve(127);
126
+ });
127
+ child.on("exit", (code) => resolve(code));
128
+ });
129
+ // The spawn error rides the stderr stream so the runner's tail (and the
130
+ // crash exit log) carry the explanation, matching the sync-throw path.
131
+ // normalizeOutputStream waits only for the spawn/error outcome when it sees a
132
+ // premature close, so a genuine read failure on a live child propagates
133
+ // immediately while an async ENOENT remains the single spawn-error signal.
134
+ const stderrWithError = async function* (): AsyncIterable<string | Uint8Array> {
135
+ if (stderrOutput !== null) yield* stderrOutput.stream;
136
+ await exited;
137
+ if (spawnError !== null) yield `spawn failed: ${(spawnError as Error).message}\n`;
138
+ };
139
+ const proc: SpawnedProcess = {
140
+ stdout: stdoutOutput === null ? emptyStream() : stdoutOutput.stream,
141
+ stderr: stderrWithError(),
142
+ exited,
143
+ disposeOutput: (): void => {
144
+ if (outputDisposed) return;
145
+ outputDisposed = true;
146
+ stdoutOutput?.dispose();
147
+ stderrOutput?.dispose();
148
+ },
149
+ ...(opts.stdin === "pipe" && child.stdin !== null
150
+ ? {
151
+ stdin: {
152
+ write: (data: string): void => {
153
+ child.stdin?.write(data);
154
+ },
155
+ end: (): void => {
156
+ child.stdin?.end();
157
+ },
158
+ },
159
+ }
160
+ : {}),
161
+ };
162
+ children.set(proc, child);
163
+ return proc;
164
+ };
165
+
166
+ const realClock: Clock = {
167
+ now: () => Date.now(),
168
+ setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
169
+ clearTimeout: (handle) => clearTimeout(handle as unknown as ReturnType<typeof setTimeout>),
170
+ };
171
+
172
+ export const nodeRunnerDeps = (extra?: Partial<RunnerDeps>): RunnerDeps => ({
173
+ spawn: realSpawn,
174
+ clock: realClock,
175
+ signal: (proc, sig) => {
176
+ children.get(proc)?.kill(sig);
177
+ },
178
+ ...extra,
179
+ });