@orkestrel/scaffold 0.0.67 → 0.0.69

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 (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +4 -4
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1567 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +507 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +445 -6
  62. package/dist/src/core/index.cjs +38 -16
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +37 -17
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +3 -3
@@ -0,0 +1,1620 @@
1
+ # Process
2
+
3
+ > A typed child-process toolkit in tiers: the supervised `Process` with framed stdout lines and a
4
+ > writable stdin channel, the byte-oriented `Session`, the buffered `execute` and `executeSync`
5
+ > runs, the fire-and-forget `detach`, and the keyed `ProcessManager` registry, none of them spawning
6
+ > through a shell.
7
+
8
+ `Process` holds its framed lines under a bounded backlog, keeps a byte-bounded stderr tail as
9
+ `evidence` beside a live `stderr` event, publishes a typed lifecycle emitter, and ends every
10
+ observation channel at one terminal moment through a bounded termination. `Session` publishes one
11
+ owned `Uint8Array` per stdout chunk instead, with an `end` that closes stdin without terminating
12
+ anything and the child's own `ending` beside the terminal `exit`. A buffered run settles with an
13
+ `ExecuteResult` carrying the captured output and the exit, while a detached child owns no stdio and
14
+ is unreferenced, so nothing in this process observes its outcome. `ProcessManager` launches and
15
+ stops its children by id and reports each moment through its own emitter. An argument a batch
16
+ target could corrupt is refused rather than passed, so a metacharacter in an argument is data
17
+ rather than syntax. The host-independent contracts, errors, constants, and types ship from
18
+ `@orkestrel/process`, and the Node implementations and Node-side contracts from
19
+ `@orkestrel/process/server`. Source: [`src/core`](../src/core) (the contracts) and
20
+ [`src/server`](../src/server) (the Node engine).
21
+
22
+ ## Surface
23
+
24
+ The tiers divide by lifetime. Reach for `Process` when you need the live stream, the stdin
25
+ channel, or the lifecycle events. Reach for `Session` when the child speaks a protocol and you need
26
+ its exact bytes rather than framed lines. Reach for `execute` or `executeSync` when you want the
27
+ buffered output and the exit in one call. Reach for `ProcessManager` when you supervise several
28
+ children by id.
29
+
30
+ ### Supervise a child and read its lines
31
+
32
+ Spawn a supervised child from `@orkestrel/process/server`, read its framed lines, and await its exit:
33
+
34
+ ```ts
35
+ import { createProcess } from '@orkestrel/process/server'
36
+
37
+ const child = createProcess({
38
+ command: { file: 'node', arguments: ['-e', 'console.log("ready"); console.log("done")'] },
39
+ workspace: process.cwd(),
40
+ grace: 5_000, // POSIX only: the window between SIGTERM and SIGKILL
41
+ })
42
+
43
+ const lines: string[] = []
44
+ for await (const line of child.lines) lines.push(line)
45
+ lines // ['ready', 'done']
46
+
47
+ const exit = await child.exit
48
+ exit.code // 0
49
+ await child.destroy()
50
+ ```
51
+
52
+ ### Factories
53
+
54
+ The interface-oriented constructors, from `@orkestrel/process/server`.
55
+
56
+ | API | Kind | Summary |
57
+ | ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `createProcess` | function | Creates one supervised child process and returns it as a `ProcessInterface`, so a caller holds the published contract rather than the `Process` class. |
59
+ | `createSession` | function | Creates one raw byte session over a supervised child process and returns it as a `SessionInterface`, so a caller holds the published contract rather than the `Session` class. |
60
+ | `createProcessManager` | function | Creates one empty keyed registry of supervised child processes and returns it as a `ProcessManagerInterface`, so a caller holds the published contract rather than the `ProcessManager` class. |
61
+
62
+ ### Spawns
63
+
64
+ The one-shot and fire-and-forget spawns, from `@orkestrel/process/server`.
65
+
66
+ | API | Kind | Summary |
67
+ | ------------- | -------- | -------------------------------------------------------------------------------------------- |
68
+ | `execute` | function | Runs one command to completion, buffering its output, and settles with the outcome. |
69
+ | `executeSync` | function | Runs one command to completion synchronously, buffering its output, and returns the outcome. |
70
+ | `detach` | function | Spawns one command as a detached process and returns without waiting for it. |
71
+
72
+ ### Classes
73
+
74
+ The classes a factory constructs and the `Supervisor` engine a consumer constructs directly, from
75
+ `@orkestrel/process/server`, and the error type from `@orkestrel/process`. `Supervisor` declares no
76
+ interface, so its readonly data members are named under [Surface notes](#surface-notes) rather than
77
+ in a Surface row.
78
+
79
+ | API | Kind | Summary |
80
+ | ---------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
81
+ | `Process` | class | Supervises one child, frames its standard output into lines under a bounded backlog, and keeps every observation channel aligned at termination. |
82
+ | `Session` | class | Supervises one child and publishes its standard output as raw bytes. |
83
+ | `Supervisor` | class | Supervises one child process and reports each lifecycle moment to the face composing it. |
84
+ | `ProcessManager` | class | Launches supervised children under caller-chosen ids, evicts each one as it settles, and destroys every live child on teardown. |
85
+ | `ProcessError` | class | Represents a child-process failure with a stable machine-readable category. |
86
+
87
+ ### Guards
88
+
89
+ The total guard, from `@orkestrel/process`.
90
+
91
+ In a guard table a `Shape` cell holds the type the guard narrows to.
92
+
93
+ | API | Kind | Shape | Summary |
94
+ | ---------------- | -------- | -------------- | ---------------------------------------------------- |
95
+ | `isProcessError` | function | `ProcessError` | Checks whether an unknown value is a `ProcessError`. |
96
+
97
+ ### Error factories
98
+
99
+ The constructors for each failure category, from `@orkestrel/process`.
100
+
101
+ | API | Kind | Summary |
102
+ | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
103
+ | `createDuplicateError` | function | Creates the `duplicate`-coded failure raised when a manager launch reuses a live id. |
104
+ | `createProtocolError` | function | Creates the `protocol`-coded failure raised when a launch is attempted on a registry that is being destroyed. |
105
+ | `createInvalidError` | function | Creates the `invalid`-coded failure raised when a public input is refused before anything is spawned. |
106
+ | `createExecuteError` | function | Creates the failure raised when a run does not complete successfully and rejection is requested. |
107
+
108
+ ### Command helpers
109
+
110
+ The resolution and environment building blocks every spawn composes, from
111
+ `@orkestrel/process/server`.
112
+
113
+ | API | Kind | Summary |
114
+ | --------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `snapshotCommand` | function | Takes one owned frozen snapshot of a caller's command. |
116
+ | `formatCommand` | function | Renders one command into its diagnostic command line. |
117
+ | `quoteArgument` | function | Quotes one command-line token for a `cmd.exe` command line. |
118
+ | `buildSpawn` | function | Builds the resolved spawn form of one command for the current host. |
119
+ | `buildPlatformSpawn` | function | Builds a spawn form from a resolved file and an explicit platform. |
120
+ | `buildExecutableCandidates` | function | Builds the executable candidates an explicit platform would search. |
121
+ | `resolveExecutable` | function | Resolves a command file to the executable path the host would launch, or to `undefined` on a POSIX host, which performs its own lookup. |
122
+ | `isFile` | function | Checks whether a path names a regular file. |
123
+ | `readVariable` | function | Reads one environment variable the way the current host resolves it. |
124
+ | `readPlatformVariable` | function | Reads one environment variable under an explicit platform's key rules. |
125
+ | `mergeEnvironment` | function | Merges environment overrides into the environment one child receives on the current host. |
126
+ | `mergePlatformEnvironment` | function | Merges environment layers under an explicit platform's key rules. |
127
+
128
+ ### Capture helpers
129
+
130
+ The byte-bounding and result-assembly building blocks, from `@orkestrel/process/server`.
131
+
132
+ | API | Kind | Summary |
133
+ | -------------------- | -------- | -------------------------------------------------------------------------------------------- |
134
+ | `trimHead` | function | Trims a buffer to at most `limit` leading bytes without splitting a UTF-8 sequence. |
135
+ | `trimTail` | function | Trims a buffer to at most `limit` trailing bytes without splitting a UTF-8 sequence. |
136
+ | `captureChunk` | function | Bounds one delivered stream chunk to the bytes a capture still has room for. |
137
+ | `buildExecuteResult` | function | Builds one settled `ExecuteResult` from a completed run's captured bytes and terminal facts. |
138
+
139
+ ### Termination helpers
140
+
141
+ The signalling and confirmation building blocks a bounded stop composes, from
142
+ `@orkestrel/process/server`.
143
+
144
+ | API | Kind | Summary |
145
+ | -------------- | -------- | ----------------------------------------------------------------------------------- |
146
+ | `isExited` | function | Checks whether a child process has reached its native exit. |
147
+ | `killProcess` | function | Signals one owned child process, or its detached process group on a POSIX host. |
148
+ | `killTree` | function | Kills one Windows process tree through `taskkill`. |
149
+ | `waitForExit` | function | Waits for one child process's native exit, bounded by a deadline. |
150
+ | `waitForClose` | function | Waits for one child process's streams to close, bounded by a deadline. |
151
+ | `stopChild` | function | Terminates one child process tree and reports whether its native exit was observed. |
152
+
153
+ ### Validators
154
+
155
+ The input refusals every public entry point runs before it spawns anything, from
156
+ `@orkestrel/process/server`.
157
+
158
+ | API | Kind | Summary |
159
+ | --------------------- | -------- | ------------------------------------------------------------------- |
160
+ | `validateText` | function | Validates one spawn-bound string. |
161
+ | `validateTimer` | function | Validates one timer-valued option in milliseconds. |
162
+ | `validateBytes` | function | Validates one byte-valued option. |
163
+ | `validateEnvironment` | function | Validates every spawn-bound string of one environment override map. |
164
+ | `validateCommand` | function | Validates every spawn-bound string of one command. |
165
+ | `validateWorkspace` | function | Validates the working directory one child starts in. |
166
+
167
+ ### Constants
168
+
169
+ The defaults and host bounds, from `@orkestrel/process`.
170
+
171
+ A `Shape` cell holds the constant's declared type.
172
+
173
+ | API | Kind | Shape | Summary |
174
+ | ---------------------- | ----- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
175
+ | `PROCESS_GRACE` | const | `number` | Names the default cooperative POSIX window, 5000 ms, between `SIGTERM` and `SIGKILL` during termination. |
176
+ | `PROCESS_CONFIRMATION` | const | `number` | Names the window, 5000 ms, a termination waits for the child's native exit after the final kill. |
177
+ | `PROCESS_DRAIN` | const | `number` | Names the default window, 1000 ms, the package waits for the child's read ends to close after the child's native exit or after a termination this package initiated, before cutting them off. |
178
+ | `PROCESS_EVIDENCE` | const | `number` | Names the default maximum retained stderr tail, 2048 bytes, for a supervised `ProcessInterface`. |
179
+ | `PROCESS_BACKLOG` | const | `number` | Names the default soft high-water mark, 10485760 bytes, for a supervised `ProcessInterface` line backlog. |
180
+ | `PROCESS_OUTPUT` | const | `number` | Names the default maximum captured bytes, 10485760 each, for a one-shot run's stdout and stderr. |
181
+ | `PROCESS_TIMER` | const | `number` | Names the largest timer delay, 2147483647 ms, the host schedules without truncating it to one. |
182
+ | `PROCESS_PATHEXT` | const | `string` | Lists the executable extensions a Windows lookup applies when the environment declares no `PATHEXT`, `.COM;.EXE;.BAT;.CMD`. |
183
+ | `PROCESS_ERROR_CODES` | const | `readonly ProcessErrorCode[]` | Lists the machine-readable failure categories a `ProcessError` carries, in declaration order: `spawn`, `timeout`, `input`, `duplicate`, `protocol`, and `invalid`. |
184
+
185
+ ### Types
186
+
187
+ The contracts and options, all from `@orkestrel/process`.
188
+
189
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
190
+
191
+ | API | Kind | Shape | Summary |
192
+ | ------------------------- | --------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
193
+ | `ProcessCommand` | interface | `{ file, arguments, environment?, input?, isolated? }` | Represents one spawnable command: the executable, its argument vector, and optional environment overrides and initial standard input. |
194
+ | `ProcessExit` | interface | `{ code, signal, drained }` | Represents the observed terminal state of a child process: its exit code or the signal that ended it, and how its observation ended. |
195
+ | `SpawnInput` | interface | `{ file, arguments, verbatim }` | Represents the resolved spawn form of one command: the executable to launch, the argument vector to pass, and whether the host receives that vector verbatim. |
196
+ | `ExecutableOptions` | interface | `{ workspace?, environment? }` | Supplies the lookup inputs for resolving a command file to an executable path. |
197
+ | `ProcessEventMap` | type | `{ stderr, error, exit }` | Represents the push observation surface of a `ProcessInterface` — the moments a fire-and-forget observer subscribes to, alongside the `lines` stream and the `exit` promise. |
198
+ | `ProcessOptions` | interface | `{ on?, error?, command, workspace, grace?, drain?, evidence?, backlog?, delivery?, writable?, signal? }` | Configures one supervised child process. |
199
+ | `ProcessInterface` | interface | `{ pid, code, signal, emitter, lines, evidence, truncated, settled, stopping, exit } plus send, stop, destroy` | Represents one supervised child process with framed output, a bounded backlog, and bounded termination. |
200
+ | `SessionEventMap` | type | `{ stdout, stderr, error, exit }` | Represents the push observation surface of a `SessionInterface` — the moments a byte-oriented observer subscribes to, alongside the `ending` and `exit` promises. |
201
+ | `SessionOptions` | interface | `{ on?, error?, command, workspace, grace?, drain?, evidence?, delivery?, signal? }` | Configures one raw byte session over a supervised child. |
202
+ | `SessionInterface` | interface | `{ pid, code, signal, emitter, evidence, settled, stopping, ending, exit } plus write, end, stop, destroy` | Represents one supervised child process read as raw bytes, with an open standard-input channel and bounded termination. |
203
+ | `ExecuteResult` | interface | `{ command, stdout, stderr, code, signal, failed, expired, aborted, truncated }` | Represents the settled outcome of a one-shot run: the buffered output and the terminal state. |
204
+ | `ExecuteInput` | interface | `{ command, stdout, stderr, code, signal, expired, aborted, truncated, limit, cause? }` | Represents the captured bytes and terminal facts one settled `ExecuteResult` is built from. |
205
+ | `ExecuteOptions` | interface | `{ workspace?, environment?, input?, timeout?, grace?, signal?, strict?, limit? }` | Configures a one-shot run. |
206
+ | `ExecuteSyncOptions` | interface | `{ workspace?, environment?, input?, timeout?, strict?, limit? }` | Configures a synchronous one-shot run. |
207
+ | `DetachOptions` | interface | `{ workspace? }` | Configures a detached fire-and-forget spawn. |
208
+ | `ProcessManagerEventMap` | type | `{ launch, exit }` | Represents the push observation surface of a `ProcessManagerInterface` — the fleet-level moments a fire-and-forget observer subscribes to. |
209
+ | `ProcessManagerOptions` | interface | `{ on?, error? }` | Configures a `ProcessManagerInterface`. |
210
+ | `ProcessManagerInterface` | interface | `{ emitter, count } plus process, processes, launch, stop, destroy` | Represents a keyed registry of live supervised child processes. |
211
+ | `ProcessErrorCode` | type | `(typeof PROCESS_ERROR_CODES)[number]` | Names the machine-readable `ProcessError` categories, derived from `PROCESS_ERROR_CODES`: `spawn`, `timeout`, `input`, `duplicate`, `protocol`, and `invalid`. |
212
+ | `ProcessErrorContext` | interface | `{ id?, command?, code?, signal?, value? }` | Represents structured context carried by a `ProcessError`. |
213
+ | `ProcessErrorOptions` | interface | `{ code, context?, cause?, result? }` | Configures a `ProcessError`. |
214
+
215
+ ### Server contracts
216
+
217
+ The Node-side contracts, from `@orkestrel/process/server`. Each sits in this face rather than
218
+ the host-independent one for its own reason: `ProcessChildInterface` names `NodeJS.Signals`, which
219
+ a host-independent contract cannot, and `SupervisorFace` names no Node type but its consumer is the
220
+ Node-only `Supervisor` engine, so the contract sits with the face that constructs one. See
221
+ [Vocabulary](#vocabulary) for the `Face` suffix.
222
+
223
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
224
+
225
+ | API | Kind | Shape | Summary |
226
+ | ----------------------- | --------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
227
+ | `ProcessChildInterface` | interface | `{ pid?, exitCode, signalCode } plus kill, once, off` | Represents the child boundary the termination helpers drive. |
228
+ | `SupervisorFace` | interface | `{ chunk, fault, relieve?, close, terminal, teardown }` | Represents the composing face's callbacks for each lifecycle moment of one supervised child. |
229
+
230
+ ### Surface notes
231
+
232
+ Every member a `Shape` cell names before `plus` is a readonly data property, so it stays a Surface
233
+ row. `ending` and `exit` are among them: a promise you await is a value the entity holds, not a call
234
+ you make. A `SupervisorFace` member is among them too: it holds a function the caller supplies
235
+ rather than declaring one the contract implements. The call-signature members after `plus` are
236
+ documented under [Methods](#methods).
237
+
238
+ The `Supervisor` class publishes readonly data members of its own: `stdout`, `pid`, `code`, `signal`,
239
+ `evidence`, `settled`, `stopping`, `ending`, and `exit`. It declares no interface, so they are named
240
+ here rather than in a Surface row. `stdout` holds the child's standard-output stream, and it is the
241
+ stream a composing face attaches its own consumer to, because the engine frames no output and owns
242
+ no observation surface. `pid`, `code`, and `signal` read the host child directly and `ending` settles
243
+ with it, while `evidence`, `settled`, and `exit` reach
244
+ [the terminal moment](#the-terminal-moment) after the read channels close or the `drain` window cuts
245
+ them off.
246
+
247
+ ## Methods
248
+
249
+ The public methods of each behavioral interface, and of the `Supervisor` class that publishes its
250
+ own. `Process` implements `ProcessInterface` exactly, `Session` implements `SessionInterface`
251
+ exactly, and `ProcessManager` implements `ProcessManagerInterface` exactly, so each table doubles as
252
+ the class's instance-method surface.
253
+
254
+ #### `ProcessInterface`
255
+
256
+ `send` writes one line to the child's stdin; `stop` and `destroy` are the lifecycle verbs. None of
257
+ them rejects. `stop` shares one termination across every call, and `destroy` returns one stable
258
+ barrier shared by every call. Each verb reaches
259
+ [the terminal moment](#the-terminal-moment) before it settles, so a caller that resumes from either
260
+ one holds a frozen `evidence`, an ended `lines`, and a settled `exit`.
261
+
262
+ | Method | Returns | Summary |
263
+ | --------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
264
+ | `send` | `Promise<boolean>` | Writes one line to the open standard-input channel. |
265
+ | `stop` | `Promise<boolean>` | Terminates the child process tree, awaits its observed exit, and reaches the terminal moment. |
266
+ | `destroy` | `Promise<void>` | Stops the child, closes its standard-input channel, reaches the terminal moment, and destroys the observation emitter. |
267
+
268
+ #### `SessionInterface`
269
+
270
+ `write` puts raw bytes on the open stdin channel and `end` closes that channel; `stop` and `destroy`
271
+ are the lifecycle verbs. None of them rejects, and each of `end`, `stop`, and `destroy` returns one
272
+ stable barrier shared by every call. `stop` and `destroy` reach
273
+ [the terminal moment](#the-terminal-moment) before they settle. `end` does not, and that is the
274
+ distinction the member exists to carry: it closes the input channel and leaves the child running.
275
+
276
+ | Method | Returns | Summary |
277
+ | --------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
278
+ | `write` | `Promise<boolean>` | Writes raw bytes to the open standard-input channel. |
279
+ | `end` | `Promise<void>` | Closes the standard-input channel and leaves the child running. |
280
+ | `stop` | `Promise<boolean>` | Terminates the child process tree, awaits its observed exit, and reaches the terminal moment. |
281
+ | `destroy` | `Promise<void>` | Stops the child, closes its standard-input channel, reaches the terminal moment, and destroys the observation emitter. |
282
+
283
+ #### `ProcessManagerInterface`
284
+
285
+ `process` and `processes` query the live registry; `launch` spawns and registers; `stop` is an
286
+ overloaded terminator; `destroy` tears the registry down. `stop` returns a `boolean` when you
287
+ name ids and `void` when you stop every child.
288
+
289
+ | Method | Returns | Summary |
290
+ | ----------- | ------------------------------- | ------------------------------------------------------------------------------------------------- |
291
+ | `process` | `ProcessInterface \| undefined` | Returns the live child under `id`, or `undefined` when none is. |
292
+ | `processes` | `readonly ProcessInterface[]` | Returns a snapshot of every live child. |
293
+ | `launch` | `ProcessInterface` | Spawns and registers one child under `id`. |
294
+ | `stop` | `Promise<boolean>` | Terminates the named children, or every live child when a call names none, and awaits their exit. |
295
+ | `stop` | `Promise<void>` | Terminates the named children, or every live child when a call names none, and awaits their exit. |
296
+ | `destroy` | `Promise<void>` | Stops every child, then destroys the registry emitter last. |
297
+
298
+ #### `ProcessChildInterface`
299
+
300
+ `kill` delivers one signal, and `once` and `off` register and release the one-shot `exit` or `close`
301
+ listener each bounded wait needs. A `ChildProcess` satisfies each of them structurally, so a caller can
302
+ drive `stopChild`, `waitForExit`, and `waitForClose` over a child it spawned itself.
303
+
304
+ | Method | Returns | Summary |
305
+ | ------ | --------- | ------------------------------------------------------------------ |
306
+ | `kill` | `boolean` | Delivers one signal to the process. |
307
+ | `once` | `unknown` | Registers a one-shot listener for the native exit or stream close. |
308
+ | `off` | `unknown` | Releases one previously registered exit or close listener. |
309
+
310
+ #### `Supervisor`
311
+
312
+ `deliver` writes raw bytes to the open stdin channel and `end` closes that channel; `stop` and
313
+ `destroy` are the lifecycle verbs. None of them rejects. `Process` and `Session` each forward their
314
+ own member to one of these, so the engine's contract is the one both faces publish under their own
315
+ names.
316
+
317
+ | Method | Returns | Summary |
318
+ | --------- | ------------------ | --------------------------------------------------------------------------------- |
319
+ | `deliver` | `Promise<boolean>` | Writes raw bytes to the open standard-input channel. |
320
+ | `end` | `Promise<void>` | Closes the standard-input channel while leaving the child running. |
321
+ | `stop` | `Promise<boolean>` | Terminates the child process tree and reaches the terminal observation moment. |
322
+ | `destroy` | `Promise<void>` | Stops the child and releases the composing face after the terminal state freezes. |
323
+
324
+ ## Supervised children
325
+
326
+ `Process` spawns one child and captures both its streams. Standard output is framed through
327
+ `readline`, including a final line written without a trailing newline. A line feed, a CRLF pair, and
328
+ a bare carriage return each terminate a line, and a CRLF split across delivered chunks joins as one
329
+ break. A child that redraws a progress bar with a carriage return therefore yields one line per
330
+ redraw, and consecutive carriage returns yield an empty line between them. This line stream is the
331
+ package's progress surface: a consumer reads a child's progress off the lines it already receives,
332
+ so the package exposes no separate progress channel. Standard error is decoded
333
+ and forwarded live as the `stderr` event, while a byte-bounded raw tail is retained as `evidence` —
334
+ the diagnostic to attach to a failed exit. The typed `emitter` also carries the child `error` cause
335
+ on a spawn fault, a `ProcessError` coded `protocol` whose cause is a host-reported standard-input
336
+ fault, and the terminal `exit`, alongside the `exit` promise.
337
+
338
+ `ProcessOptions` requires `command` and `workspace`; the rest are optional:
339
+
340
+ | Option | Type | Required | Meaning |
341
+ | ----------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
342
+ | `command` | `ProcessCommand` | yes | The executable, its argument vector, and optional environment overrides and stdin `input`. |
343
+ | `workspace` | `string` | yes | The working directory the child runs in. |
344
+ | `grace` | `number` | no | POSIX milliseconds between `SIGTERM` and `SIGKILL`. Default: `PROCESS_GRACE` (`5_000`). |
345
+ | `drain` | `number` | no | Milliseconds the package waits for the child's read ends to close after its ending, before cutting them off; `0` cuts them off immediately. Default: `PROCESS_DRAIN` (`1_000`). |
346
+ | `evidence` | `number` | no | Maximum retained stderr tail in bytes. Default: `PROCESS_EVIDENCE` (`2_048`). |
347
+ | `backlog` | `number` | no | Soft high-water mark in bytes; termination retains at most twice `backlog`. Default: `PROCESS_BACKLOG`. |
348
+ | `delivery` | `number` | no | Milliseconds an unconfirmed `send` waits before resolving `false`; `0` or omitted disables the bound. |
349
+ | `writable` | `boolean` | no | When `true`, stdin stays open for `send`; when `false` or omitted, stdin closes after any initial `input`. |
350
+ | `signal` | `AbortSignal` | no | Aborting this signal terminates the child through the same bounded `stop`. |
351
+ | `on` | `EmitterHooks<ProcessEventMap>` | no | Initial `stderr`, `error`, and `exit` listeners installed at construction. |
352
+ | `error` | `EmitterErrorHandler` | no | Receives a listener's throw, isolated from the engine. |
353
+
354
+ There is no completion deadline for a running child. Nothing here ends a child that is still
355
+ working, the `exit` promise carries no deadline of its own, and a caller that wants one arms its own
356
+ timer and calls `stop`. `drain` does not weaken that: it bounds the window between the child's
357
+ ending and the release of its read ends. The child's native exit arms that window, and a termination
358
+ this package initiated arms it too, so every ending reaches the bound. The cutoff ends
359
+ observation; it does not terminate the child.
360
+
361
+ Every numeric option is validated at construction. A timer value outside `[0, PROCESS_TIMER]`, a
362
+ negative or fractional byte value, and a `backlog` under `1` each throw a `ProcessError` coded
363
+ `invalid` before anything is spawned, and so does a spawn-bound command string that is empty when
364
+ required or carries a NUL character. `input` is standard-input payload and carries no NUL
365
+ restriction.
366
+
367
+ Every option and command property is read once, before the child is spawned. Reading a property runs
368
+ whatever getter you put behind it, so hoisting those reads is what keeps a construction failure from
369
+ leaving a live child nobody holds: a getter that throws does so while nothing has started.
370
+
371
+ ### Byte sessions
372
+
373
+ `Session` supervises the same child and publishes its standard output as raw bytes. Each chunk the
374
+ host delivers becomes one `stdout` event carrying an owned `Uint8Array` — the session's own copy, and
375
+ a plain `Uint8Array` rather than a Node `Buffer` — so you keep it, mutate it, and concatenate it
376
+ without reaching memory the host still manages and without depending on a host type. Nothing is
377
+ framed and nothing is decoded on that side. A chunk boundary is the host's, so one event is not one
378
+ message and a line feed inside a payload starts no new event; you frame the concatenated bytes
379
+ yourself. Standard error is decoded and forwarded as `stderr` with the same byte-bounded `evidence`
380
+ tail a `Process` retains, because a diagnostic stream is read rather than parsed.
381
+
382
+ Choose by what the child's output is. `Process` frames output a person or a log reads. `Session`
383
+ delivers output a parser reads — a length-prefixed protocol, a binary payload, anything a line framer
384
+ would corrupt.
385
+
386
+ `SessionOptions` is `ProcessOptions` without `backlog` and without `writable`. A session retains no
387
+ lines, so no backlog bound applies to it; its stdin channel is open from the spawn until `end` closes
388
+ it, so no switch selects whether one exists.
389
+
390
+ `SessionOptions` requires `command` and `workspace`; the rest are optional:
391
+
392
+ | Option | Type | Required | Meaning |
393
+ | ----------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
394
+ | `command` | `ProcessCommand` | yes | The executable, its argument vector, and optional environment overrides and stdin `input`. |
395
+ | `workspace` | `string` | yes | The working directory the child runs in. |
396
+ | `grace` | `number` | no | POSIX milliseconds between `SIGTERM` and `SIGKILL`. Default: `PROCESS_GRACE` (`5_000`). |
397
+ | `drain` | `number` | no | Milliseconds the package waits for the child's read ends to close after its ending, before cutting them off; `0` cuts them off immediately. Default: `PROCESS_DRAIN` (`1_000`). |
398
+ | `evidence` | `number` | no | Maximum retained stderr tail in bytes. Default: `PROCESS_EVIDENCE` (`2_048`). |
399
+ | `delivery` | `number` | no | Milliseconds an unconfirmed `write` waits before resolving `false`; `0` or omitted disables the bound. |
400
+ | `signal` | `AbortSignal` | no | Aborting this signal terminates the child through the same bounded `stop`. |
401
+ | `on` | `EmitterHooks<SessionEventMap>` | no | Initial `stdout`, `stderr`, `error`, and `exit` listeners installed at construction. |
402
+ | `error` | `EmitterErrorHandler` | no | Receives a listener's throw, isolated from the engine. |
403
+
404
+ A session names the child's ending and the supervision's ending apart, because a transport acts on
405
+ each differently. `ending` settles at the child's own native exit and resolves no value: `code` and
406
+ `signal` already carry the terminal facts, so a second copy of them here could only drift from them.
407
+ `exit` settles at [the terminal moment](#the-terminal-moment), which is that native exit plus at most
408
+ `drain` while a descendant holds the inherited read ends. Race a cooperative shutdown window against
409
+ `ending`; a window raced against `exit` escalates against a child that already ended.
410
+
411
+ `end` closes the stdin channel and leaves the child running. That is the cooperative shutdown a
412
+ protocol client runs: end the input, let the child finish its own work, and terminate only when it
413
+ does not. It is the one member on either face that does not reach the terminal moment.
414
+
415
+ ```ts
416
+ import { Buffer } from 'node:buffer'
417
+ import { createSession } from '@orkestrel/process/server'
418
+
419
+ const session = createSession({
420
+ command: { file: 'node', arguments: ['-e', 'process.stdin.pipe(process.stdout)'] },
421
+ workspace: process.cwd(),
422
+ })
423
+
424
+ const received: Uint8Array[] = []
425
+ session.emitter.on('stdout', (chunk) => received.push(chunk))
426
+
427
+ await session.write(new TextEncoder().encode('ping')) // true — the host accepted the bytes
428
+ await session.end() // the child sees end of input; nothing terminated it
429
+ await session.ending // it exited on its own
430
+
431
+ Buffer.concat(received).toString('utf8') // 'ping' — the exact bytes, with no terminator added
432
+ const exit = await session.exit
433
+ exit.code // 0
434
+ session.stopping // false — no termination was initiated
435
+ await session.destroy()
436
+ ```
437
+
438
+ ### The terminal moment
439
+
440
+ The child's ending and the supervision's ending are distinct, and telling them apart is what this
441
+ surface is shaped around.
442
+
443
+ - **The child's ending** is the host's own record of the process: `pid`, `code`, and `signal`. They
444
+ carry the native exit as soon as the host records it.
445
+ - **The supervision's ending** is the terminal moment: `settled`, `exit`, `evidence`, and `lines`.
446
+ They reach it together.
447
+
448
+ `Session` gives the child's ending a member of its own, `ending`, which settles at the native exit
449
+ and resolves no value. `Process` carries no such member: a line consumer reads that ending from
450
+ `code` and `signal`, which have always reported it.
451
+
452
+ The terminal moment arrives when the child's streams close, or when the `drain` bound elapses first.
453
+ The child's native exit arms that bound, and so does a termination this package initiated, so a
454
+ natural exit, a spawn fault, `stop`, `destroy`, and an abort of the `signal` option each reach the
455
+ moment. `ProcessExit.drained` reports which way it arrived: `true` for the close, `false` for the
456
+ cutoff.
457
+
458
+ The endings separate whenever a descendant inherited the child's stdio, because that descendant
459
+ holds the pipe open after the child itself is gone. Inside that window `code` and `signal` carry the
460
+ terminal pair while `exit` is still pending, so read the child's ending from those fields and the
461
+ supervision's ending from `settled`.
462
+
463
+ At the terminal moment every observation surface stops moving, together:
464
+
465
+ - `evidence` freezes and never moves again. Every later read returns the same string, so a consumer
466
+ needs no private copy of the tail and cannot watch it move under them.
467
+ - `lines` ends rather than throwing, so teardown is not an error path: a pending `next` call resolves
468
+ `done: true` and a `for await` loop exits normally. Every line already framed and queued is
469
+ delivered before that end, so a consumer that stops a chatty child still reads what the child had
470
+ framed. A cutoff loses more than the bytes that arrive after it: only a stream's own end flushes a
471
+ trailing partial, so a final line the child wrote without a newline is dropped however early it
472
+ was written.
473
+ - `exit` settles with its `ProcessExit` value, and `settled` turns `true`.
474
+ - The abort listener registered on the caller's `signal` option is released here, rather than at the
475
+ `destroy` call that preceded it.
476
+
477
+ A `Session` reaches the same moment through the same paths, with its byte events in place of `lines`:
478
+ the last `stdout` and `stderr` events are delivered before the `exit` event, and none follows it.
479
+
480
+ `end` is the one member on either face that does not reach the moment. It closes a session's stdin
481
+ channel and leaves the child running, so `stopping` stays false, no drain window is armed, and `exit`
482
+ stays pending until the child ends itself or something terminates it. Every other verb here ends the
483
+ observation; `end` ends only the input.
484
+
485
+ `stop` reaches the terminal moment as well as `destroy` does. A caller that terminates a child and
486
+ keeps reading it therefore reaches the end of the stream instead of waiting on a child it already
487
+ ended, and needs no second call to release it. `stopping` reports the initiation rather than the
488
+ arrival: it turns `true` when `stop`, `destroy`, or an abort of `signal` begins a termination, and it stays
489
+ `true` from then on, including after `settled` turns `true`. A child that exited on its own reports
490
+ `stopping` as `false` with `settled` as `true`.
491
+
492
+ A consumer handed the terminal value never reads a child that still reports itself unfinished. The
493
+ latch runs before the `exit` event and before the read ends are released, so a listener on that
494
+ event reads `settled` as `true` from inside the delivery that handed it the value.
495
+
496
+ `drained` lives on `ProcessExit` rather than on the child, because that value exists only at the
497
+ terminal moment. A getter would admit a read before the moment arrives, which is the defect this
498
+ contract removes.
499
+
500
+ ```ts
501
+ import { createProcess } from '@orkestrel/process/server'
502
+
503
+ const child = createProcess({
504
+ command: { file: 'node', arguments: ['-e', 'console.error("done")'] },
505
+ workspace: process.cwd(),
506
+ drain: 1_000, // the bound on the wait for the child's read ends to close
507
+ })
508
+
509
+ const exit = await child.exit
510
+ exit.drained // true — the child's own streams closed
511
+ child.settled // true — evidence is frozen and lines has ended
512
+ child.stopping // false — this child ended on its own
513
+ child.evidence // 'done\n' — the frozen tail every later read returns
514
+ await child.destroy()
515
+ ```
516
+
517
+ #### The drain bound
518
+
519
+ `drain` bounds how long the package waits for the child's read ends to close, and defaults to
520
+ `PROCESS_DRAIN`. The TSDoc on that constant carries the measurement behind the value and the date it
521
+ was taken.
522
+
523
+ The child's native exit arms the bound, which is what carries a natural exit to
524
+ the terminal moment when a descendant holds the read ends open. The return of a termination this
525
+ package initiated arms it too, confirmed or not, so a `stop` whose confirmation window elapsed while
526
+ the child was still running reaches the cutoff with `code` and `signal` still `null`.
527
+
528
+ The close latency is bimodal rather than long-tailed, so `drain` is a bound on the unbounded case
529
+ and not a percentile of a distribution. Measured on Windows 11 with Node v24.18.1 on 2026-08-21,
530
+ every ordinary close landed within 0.02ms of the native exit, and a `taskkill /F /T` that reaped a
531
+ descendant while the root was still alive closed within 0.01ms. Every late close in that fixture set
532
+ came from a descendant holding the inherited pipe: one that ends on its own closes late by its own
533
+ remaining life, and one that never ends never closes at all. There is no finite tail for a
534
+ percentile to cover.
535
+
536
+ Pass `drain: 0` for an immediate cutoff. That reads against the sibling `delivery`, whose `0`
537
+ disables its bound, and the difference is deliberate: an unbounded drain is the defect `drain`
538
+ exists to prevent, so no value requests one.
539
+
540
+ Read `drained` when the diagnostics matter. `drained: true` reports that the child's streams closed,
541
+ so `evidence` holds everything the child wrote. `drained: false` reports that the bound elapsed
542
+ first, so `evidence` is the tail as of the cutoff and later diagnostics may have existed. `drained`
543
+ and `truncated` are independent facts about different streams, and one child reports both when a
544
+ retention bound dropped stdout lines and the drain bound cut stderr off.
545
+
546
+ ### The line backlog
547
+
548
+ `lines` is a single-consumer stream. Each line goes to exactly one waiting iterator, so concurrent
549
+ iterators over the same child split the output between them rather than each receiving all of it.
550
+ Iterate it once, and fan out from that loop when several readers need the same lines.
551
+
552
+ The `lines` policy follows consumer intent, and `backlog` bounds the unconsumed backlog in bytes.
553
+
554
+ - After an iterator has been requested, stdout pauses at the `backlog` mark and resumes at half of
555
+ it. That consumer loses nothing before termination, and the child feels real backpressure.
556
+ - While no iterator has ever been requested, stdout keeps draining so `exit` still resolves, and
557
+ retention stops at the mark. A consumer attaching after that point receives the retained head, then
558
+ a gap, then the live stream.
559
+
560
+ The mark is soft in the pausing direction. `readline` frames every line a delivered chunk carries
561
+ before a pause takes effect, so the ordinary backlog can pass the mark by the line that crossed it
562
+ plus the rest of that chunk. Termination releases the pause and never reapplies it, because a paused
563
+ stdout holds the child's own write and therefore its exit. The teardown drain retains at most twice
564
+ `backlog`; it drops later lines without pausing stdout. The `truncated` property becomes `true` when
565
+ either the no-consumer mark or the termination cap omits a line, so a consumer can detect the gap.
566
+
567
+ A retained line costs its payload bytes plus one byte for the break that framed it, whichever
568
+ terminator the child wrote, so a line carrying no payload still costs a byte. That is what bounds a
569
+ flood of empty lines, which would otherwise be free and defeat the mark entirely.
570
+
571
+ ### Standard input
572
+
573
+ `writable: true` keeps stdin open for `send`. `send` never rejects and never throws: it resolves
574
+ `true` when the host accepted the bytes without reporting a fault, and `false` when the channel was
575
+ closed, destroyed, ended, failed, or left the write unconfirmed through `delivery`. Acceptance is a
576
+ fact about the host's pipe rather than about the child: it does not prove that the child read the
577
+ bytes, and it does not prove that the child ever will.
578
+
579
+ After `stop` or `destroy` begins, a later `send` call resolves `false`. Version 0.0.4 could resolve
580
+ that call `true` before teardown destroyed the pipe. The narrower answer avoids claiming delivery
581
+ for bytes the package is about to discard.
582
+
583
+ A host-reported fault on the channel surfaces rather than being swallowed. The affected `send`
584
+ resolves `false`, and the `error` event carries a `ProcessError` coded `protocol` whose `cause` is
585
+ the host fault, so a message lost to a dying child is an event you can act on. The channel holds one
586
+ failure state: the write callback and the stream error report the same fault once, and every later
587
+ `send` resolves `false` with no further event.
588
+
589
+ A channel the package or consumer has ended stays quiet for its remaining life. A `stop`, a
590
+ `destroy`, or a channel that was never writable settles every pending write `false` and emits
591
+ nothing. The constructor-supplied `input` write and its closing `end` form the initial input phase;
592
+ a fault arising from that sequence also emits nothing and creates no channel-failure state. Only a
593
+ `writable: true` channel that has not yet ended surfaces a later host fault as `protocol`.
594
+
595
+ An ordinary write settles as soon as the kernel accepts it. A write larger than the host's pipe
596
+ buffer to a child that never reads it can fill the pipe and remain unconfirmed. `delivery` bounds
597
+ that wait: an unconfirmed write resolves `false` after the given milliseconds, and no event fires,
598
+ because the bound expiring is not a fault the host reported. Omit `delivery`, or pass `0`, and the
599
+ write stays pending until the channel faults or teardown settles it.
600
+
601
+ Neither mechanism proves delivery, so a consumer that needs a deadline still arms its own timer and
602
+ calls `stop`. On Windows 11 with Node v24.18.1, measured on 2026-08-21, a child that closes its own
603
+ file descriptor 0 can leave the parent's pipe writable: `send` resolves `true` and no fault is ever
604
+ reported while that child stays alive, so `true` there records bytes taken into a pipe nobody will
605
+ read. After that child exits, `send` resolves `false` because the channel is closed, and a write
606
+ still pending when it exits fails with the host's `EOF` and arrives as the `protocol` error.
607
+
608
+ ```ts
609
+ import { createProcess } from '@orkestrel/process/server'
610
+
611
+ const echo = createProcess({
612
+ command: {
613
+ file: 'node',
614
+ arguments: ['-e', 'process.stdin.on("data", (chunk) => process.stdout.write(chunk))'],
615
+ },
616
+ workspace: process.cwd(),
617
+ writable: true,
618
+ })
619
+
620
+ await echo.send('ping') // true — the host accepted the bytes
621
+ await echo.stop() // true — the native exit was observed
622
+ await echo.destroy()
623
+ ```
624
+
625
+ A `Session` opens the same channel with no switch and writes to it with `write` rather than `send`.
626
+ `write` puts the exact bytes on the channel and appends nothing, so a caller framing its own protocol
627
+ composes the header and the delimiter itself. Every preceding refusal holds for `write` unchanged: it
628
+ never rejects, and it resolves `false` for a channel that was closed, destroyed, ended, or failed,
629
+ for a write left unconfirmed through `delivery`, and for a call made after `stop` or `destroy` began.
630
+ The host can queue the payload, so treat the array you passed as owned by the channel until the
631
+ returned promise settles.
632
+
633
+ `end` closes a session's channel without terminating anything. Every call shares one barrier, which
634
+ resolves after the host flushes the writes it had already accepted. That flush carries no bound of
635
+ its own: a child that stops reading its input leaves the accepted bytes in the pipe, and the barrier
636
+ stays pending for as long as they sit there. Race `end` against a window of your own when you need a
637
+ bound, the way [Close a byte session cooperatively](#close-a-byte-session-cooperatively) does, rather
638
+ than awaiting it bare. A `write` after `end` resolves
639
+ `false`, because the host stops reporting an ended channel writable — that refusal is derived from
640
+ the channel's state rather than declared by a second flag. The ended channel then stays quiet for its
641
+ remaining life, exactly as a package-ended one does: a later host fault on it settles pending writes
642
+ and emits no `error` event.
643
+
644
+ `ProcessInterface` carries no `end`, because a `Process` fixes its channel's lifetime at
645
+ construction. `writable: false` or omitted closes stdin after any initial `input`, and `writable:
646
+ true` keeps it open until termination.
647
+
648
+ ### Termination
649
+
650
+ `stop` terminates the child tree, awaits its observed exit, and reports whether that exit arrived. It
651
+ resolves `true` when the child's native exit was observed and `false` when the confirmation wait
652
+ elapsed without it. Every call shares one termination, and the liveness fields are read again before
653
+ each signal, so no signal is initiated after an observed exit. The window between initiating a
654
+ signal and the host delivering it belongs to the operating system, which is the limit of what any
655
+ caller can hold. `destroy` runs `stop`, destroys stdin so every pending `send` settles, then destroys
656
+ the emitter last; it always resolves, including when termination was never confirmed.
657
+
658
+ `stop` and `destroy` each settle after [the terminal moment](#the-terminal-moment), bounded by
659
+ `drain`, so a descendant holding an inherited pipe cannot hold either one open. `destroy` destroys the emitter
660
+ after the frozen state exists, so a consumer watching the `stderr` event and a consumer reading
661
+ `evidence` end on the same bytes.
662
+
663
+ `Session` terminates through the same verbs with the same bounds and the same barriers. Run its
664
+ cooperative shutdown first where the child supports one: call `end`, race `ending` against a window
665
+ of your own, and call `stop` when that window expires.
666
+
667
+ POSIX detachment creates the process group that tree termination signals. The child therefore
668
+ survives the supervisor's `SIGKILL` and does not receive the terminal's `SIGINT`. Call `stop` or
669
+ `destroy` during an orderly shutdown.
670
+
671
+ No bounded wait leaks a listener. `destroy` removes the abort listener it registered on the caller's
672
+ `signal`, so a long-lived controller does not accumulate one per child; the release happens at the
673
+ terminal moment, which is where every other observation surface is released, rather than at the
674
+ `destroy` call. `waitForExit` releases its own `exit` listener when its deadline elapses before the
675
+ exit, and `waitForClose` releases its own `close` listener the same way, so a child driven through
676
+ several bounded waits accumulates none either. `ProcessChildInterface` declares the `off` that
677
+ release needs, so a caller composing a stop of its own from the published contract releases it too.
678
+
679
+ `PROCESS_CONFIRMATION` bounds each awaited step of a stop rather than the stop as a whole, so a
680
+ worst-case termination spends more than one of them. On a POSIX host the cooperative wait after
681
+ `SIGTERM` is bounded by `grace` and the wait after `SIGKILL` by `PROCESS_CONFIRMATION`. On Windows
682
+ the `taskkill` call is bounded by `PROCESS_CONFIRMATION` and the wait that follows it by another.
683
+
684
+ `executeSync` ends only the root process when its `timeout` elapses. A descendant can remain live
685
+ because the synchronous host exposes no tree-termination phase. Use `execute` or `Process` when the
686
+ timeout must terminate the child tree.
687
+
688
+ POSIX and Windows terminate differently, and only POSIX has a cooperative phase.
689
+
690
+ | Host | Sequence | `grace` |
691
+ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
692
+ | POSIX | `SIGTERM` to the process group, wait `grace`, then `SIGKILL` to the group — each signal reaching the child directly when no group owns its pid. | used |
693
+ | Windows | `taskkill /F /T` on the whole tree at once, with a direct kill after the utility reports failure. | not used |
694
+
695
+ Windows has no signal a process group can receive, so `killTree` ends the tree through the
696
+ `taskkill.exe` utility addressed by its absolute `System32` path, which stops a `PATH` override
697
+ substituting another program. That call is bounded by the confirmation window and is killed itself
698
+ when the window elapses. A tree is discoverable only while its root lives, so a descendant that
699
+ outlives the root is beyond this mechanism; Windows job objects, which would close that gap, are not
700
+ part of this package.
701
+
702
+ `stopChild` therefore returns for an already-exited child before any route to its pid runs, on every
703
+ host. The host has reaped that number and can have handed it to another process, so signalling it or
704
+ naming it to `taskkill` reaches a process this package never spawned. Nothing recoverable is given
705
+ up by returning: measured on Windows 11 with Node v24.18.1 on 2026-08-21, `taskkill /F /T` against a
706
+ live root reaped every descendant in the fixture set, and against an exited root it reported the
707
+ process as not found while the descendant kept the pipe open and delivered for a further 2.28s. A
708
+ descendant whose root has exited is beyond every mechanism here, and the `drain` bound is what ends
709
+ the wait for it.
710
+
711
+ After an undrained cutoff, no mechanism here can report whether further diagnostics existed, and that
712
+ is a limit rather than an omission. Node exposes no count of the writers still holding a pipe, and a
713
+ descendant that outlives its root is beyond `taskkill /F /T`, so nothing observable settles the
714
+ question. `drained: true` carries no such limit: the read ends closed, so `evidence` holds everything
715
+ the child wrote. Start a teardown while the root is still alive to keep the ordinary case on that
716
+ branch.
717
+
718
+ `killProcess` is the direct signalling helper underneath. On a POSIX host it signals the negated pid,
719
+ which reaches the detached child's whole process group. When the host reports that no group owns the
720
+ pid, it falls back to signalling the child directly, so `killProcess` and `stopChild` also support a
721
+ non-detached child. On Windows, or when no pid is available, it signals the child alone. Every other
722
+ throw during signalling is swallowed, because the process can exit between the caller's liveness
723
+ check and the call, and the child's native exit stays the authoritative terminal state.
724
+
725
+ `pid` is the host id of the spawned child, and `code` and `signal` are the terminal pair the host
726
+ recorded for it. The spawn is eager, so `pid` carries a number by the time `createProcess` returns,
727
+ and a spawn that produced no child reports `undefined` for that child's whole lifetime. `code` and
728
+ `signal` are `null` while the child runs; the host records them at the native exit, which arrives
729
+ before the terminal moment whenever a descendant holds the child's stdio open, so a supervisor
730
+ inside that window reads the child's own ending from them. A spawn fault records the host's negative
731
+ errno as the `code`, the same value the `exit` promise carries.
732
+
733
+ An assigned id survives the exit, so `pid` reports no liveness on its own. Derive liveness as
734
+ `pid !== undefined && code === null && signal === null`, and derive it again before each use of the
735
+ id, because the host reuses a dead child's id and a signal sent to a reused id reaches the process
736
+ that holds it. Address a live child yourself with `process.kill(pid, 'SIGTERM')`; on a POSIX host,
737
+ negate the id to reach the detached child's whole process group, which is the route `killProcess`
738
+ takes.
739
+
740
+ ```ts
741
+ import { createProcess } from '@orkestrel/process/server'
742
+
743
+ const controller = new AbortController()
744
+ const child = createProcess({
745
+ command: { file: 'node', arguments: ['server.js'] },
746
+ workspace: process.cwd(),
747
+ grace: 2_000, // POSIX only: the window between SIGTERM and SIGKILL
748
+ signal: controller.signal, // abort terminates through the same bounded stop
749
+ on: {
750
+ stderr: (chunk) => process.stderr.write(chunk),
751
+ exit: ({ code }) => console.log(`server exited with code ${String(code)}`),
752
+ },
753
+ })
754
+
755
+ controller.abort()
756
+ const { drained } = await child.exit // false when the drain bound cut the streams off
757
+ console.log(child.evidence) // the frozen stderr tail, at most PROCESS_EVIDENCE bytes
758
+ console.log(drained)
759
+ await child.destroy()
760
+ ```
761
+
762
+ ## Command resolution
763
+
764
+ No spawn in this package uses an implicit shell. `buildSpawn` resolves one command into the file, the
765
+ argument vector, and a `verbatim` flag, and every entry point — `Process`, `execute`, `executeSync`,
766
+ and `detach` — spawns through it. The host boundary passes its platform into the pure candidate and
767
+ batch decisions. A metacharacter in an argument therefore reaches the child as data rather than as
768
+ syntax: the one path that builds a command line at all — a Windows `.cmd` or `.bat` script — runs
769
+ through an explicit quoted `cmd.exe /d /s /c` invocation, and the one argument that invocation could
770
+ corrupt is refused rather than passed.
771
+
772
+ A POSIX platform input leaves the file lookup to `execvp`, so `resolveExecutable` returns
773
+ `undefined` and the command file is spawned as written. A Windows platform input needs the lookup,
774
+ because the host searches the working directory before `PATH` and applies `PATHEXT`, and Node
775
+ reproduces neither for a direct spawn. `buildExecutableCandidates` makes that decision and the
776
+ ordered candidate list pure, so every platform input executes on every test host.
777
+ Within each searched directory, `buildExecutableCandidates` enumerates `PATHEXT` candidates for an
778
+ extensionless name. For an extension-bearing name, it enumerates the literal path before `PATHEXT`
779
+ candidates: `report.txt` resolves to a `report.txt` file where one exists, and to `report.txt.cmd`
780
+ where none does. The lookup reads the child's effective environment, so an overridden `PATH`
781
+ selects the executable the child would have found, it falls back to `PROCESS_PATHEXT` when the
782
+ environment declares no `PATHEXT`, and it accepts a candidate only when `isFile` reports a regular
783
+ file. When no candidate is a regular file, `resolveExecutable` returns `undefined` and `buildSpawn`
784
+ preserves the command file as written for native spawning.
785
+
786
+ A resolved `.cmd` or `.bat` script cannot be spawned directly. `buildSpawn` runs it through an
787
+ explicitly quoted `cmd.exe /d /s /c` command line and sets `verbatim`, so the host receives that line
788
+ as written. `quoteArgument` wraps a token carrying whitespace or a metacharacter in double quotes and
789
+ doubles an embedded quote. `quoteArgument` includes `%` in the quoted set, so `quoteArgument('%1')`
790
+ returns `"%1"`. Quoting does not prevent percent expansion, which is why the batch path refuses that
791
+ argument before spawning.
792
+
793
+ One argument cannot survive that command line: `cmd.exe` expands `%NAME%` before it parses quotes,
794
+ so no quoting carries a percent sign through to a batch target. On Windows, `buildSpawn` refuses an
795
+ argument carrying `%` when the resolved target is `.cmd` or `.bat`, with a `ProcessError` coded
796
+ `invalid` carrying the argument on `context.value`. The batch path has these outcomes: an argument
797
+ reaches the child as written, or the call fails. No path rewrites one. Off the batch path a percent
798
+ sign is ordinary text and passes untouched.
799
+
800
+ Because no spawn passes `shell: true`, Node's `DEP0190` deprecation warning — which fires when a
801
+ `.bat` or `.cmd` file is spawned through a shell with arguments — cannot come from this package.
802
+
803
+ The whole batch path is Windows-only, extension and all. A POSIX host has no `cmd.exe` and no
804
+ restriction on spawning a file directly, so a target named `worker.cmd` spawns directly there, keeps
805
+ `verbatim` at `false`, and receives a percent sign as literal text. The extension classifies a target
806
+ only on the host where the extension means something.
807
+
808
+ Every entry point snapshots the command before it validates, through `snapshotCommand`. The object
809
+ validated is the object spawned: each property is read exactly once, so a `file` getter that returns
810
+ one executable to the validator and another to the spawn cannot exist, and the argument vector and
811
+ the environment record are copied and frozen, so a caller mutating either after the call cannot reach
812
+ the child.
813
+
814
+ ```ts
815
+ import {
816
+ buildExecutableCandidates,
817
+ buildSpawn,
818
+ formatCommand,
819
+ quoteArgument,
820
+ snapshotCommand,
821
+ } from '@orkestrel/process/server'
822
+
823
+ snapshotCommand({ file: 'git', arguments: ['status'] }) // { file: 'git', arguments: ['status'] }
824
+
825
+ formatCommand({ file: 'git', arguments: ['status'] }) // 'git status'
826
+ quoteArgument('status') // 'status'
827
+ quoteArgument('a&b') // '"a&b"'
828
+ quoteArgument('%1') // '"%1"'
829
+ buildExecutableCandidates('git', 'C:\\work', { PATH: 'C:\\bin' }, 'win32')
830
+ // [
831
+ // 'C:\\work\\git.COM', 'C:\\work\\git.EXE', 'C:\\work\\git.BAT', 'C:\\work\\git.CMD',
832
+ // 'C:\\bin\\git.COM', 'C:\\bin\\git.EXE', 'C:\\bin\\git.BAT', 'C:\\bin\\git.CMD',
833
+ // ]
834
+ buildSpawn({ file: 'node', arguments: ['--version'] }).verbatim // false
835
+ ```
836
+
837
+ ### The child environment
838
+
839
+ `mergeEnvironment` builds the environment one child receives. Later maps override earlier ones, an
840
+ `undefined` value unsets a key, and on Windows the keys fold case-insensitively with the last writer
841
+ winning, so `PATH` followed by `Path` yields one variable rather than a rival pair the host would
842
+ resolve unpredictably. `readVariable` reads one variable back under the same folding rule. Their
843
+ `mergePlatformEnvironment` and `readPlatformVariable` leaves accept an explicit platform, so every
844
+ folding decision executes on every test host.
845
+
846
+ `ProcessCommand.isolated` decides whether the parent environment is a layer at all. Omitted or
847
+ `false`, the overrides merge over the parent environment; `true`, the child environment is the
848
+ overrides alone from this package's side. That qualification is exact on Windows: libuv injects a
849
+ host set — `PATH`, `SYSTEMROOT`, `TEMP`, `USERPROFILE`, and several more — into any explicit
850
+ environment, so an isolated child there still receives those variables from the host.
851
+ On a POSIX host, `isolated: true` removes `PATH`, so pass an absolute file or include `PATH` in the
852
+ overrides when the child uses a bare command name.
853
+
854
+ ```ts
855
+ import { mergeEnvironment } from '@orkestrel/process/server'
856
+
857
+ mergeEnvironment(true, { TOKEN: 'a' }) // { TOKEN: 'a' } — the overrides alone
858
+ mergeEnvironment(false, { TOKEN: 'a' }, { TOKEN: undefined }).TOKEN // undefined — the override unset it
859
+ ```
860
+
861
+ Read the difference back from the child rather than from the merge, because the host has the last
862
+ word on it:
863
+
864
+ ```ts
865
+ import { executeSync } from '@orkestrel/process/server'
866
+
867
+ const printer = 'process.stdout.write(Object.keys(process.env).sort().join(","))'
868
+ const keys = executeSync({
869
+ file: process.execPath,
870
+ arguments: ['-e', printer],
871
+ environment: { TOKEN: 'a' },
872
+ isolated: true,
873
+ }).stdout.split(',')
874
+
875
+ keys.includes('TOKEN') // true — the override reached the child
876
+ keys.includes('SYSTEMROOT') // true on Windows, false on a POSIX host
877
+ ```
878
+
879
+ ## One-shot runs
880
+
881
+ The `execute` function runs a command to completion, buffers its output, and resolves an
882
+ `ExecuteResult`. The `executeSync` function is the blocking counterpart. Use either when you want
883
+ the exit and the captured output together, without managing a live stream.
884
+
885
+ `ExecuteOptions` are all optional:
886
+
887
+ | Option | Type | Default | Meaning |
888
+ | ------------- | ------------------------------------- | --------------------- | ------------------------------------------------------------------------ |
889
+ | `workspace` | `string` | current directory | The working directory. |
890
+ | `environment` | `Record<string, string \| undefined>` | inherit the parent | Overrides applied last; `undefined` unsets a key. |
891
+ | `input` | `string` | the command's `input` | Standard-input payload, including NUL, overriding `command.input`. |
892
+ | `timeout` | `number` | `0` (disabled) | Milliseconds before the child is terminated; `0` or omitted disables it. |
893
+ | `grace` | `number` | `PROCESS_GRACE` | The POSIX `SIGTERM` to `SIGKILL` window when a timeout or abort ends it. |
894
+ | `signal` | `AbortSignal` | none | Aborting this signal terminates the run and reports `aborted`. |
895
+ | `strict` | `boolean` | `true` | When `false`, resolve with the result on failure instead of rejecting. |
896
+ | `limit` | `number` | `PROCESS_OUTPUT` | Maximum captured bytes for stdout and for stderr, each. |
897
+
898
+ `ExecuteSyncOptions` carries the same set without `grace` and without `signal`, because the
899
+ synchronous host offers neither a cooperative termination window nor in-flight cancellation.
900
+
901
+ Every option and command property is read once, before the child is spawned, in `execute` and in
902
+ `executeSync` alike. The value validated is therefore the value spawned, whatever a getter behind an
903
+ option returns on a later read, and a getter that throws does so while nothing has started.
904
+
905
+ `input` is standard-input payload and carries no NUL restriction on either option shape:
906
+
907
+ ```ts
908
+ import { executeSync } from '@orkestrel/process/server'
909
+
910
+ const input = `left${String.fromCodePoint(0)}right`
911
+ const echoed = executeSync(
912
+ { file: process.execPath, arguments: ['-e', 'process.stdin.pipe(process.stdout)'] },
913
+ { input },
914
+ )
915
+ echoed.stdout === input // true
916
+ ```
917
+
918
+ The `execute` function writes `input` with a host callback. A fault while that write is pending ends
919
+ the run by design and makes `failed` true. With `strict: true`, the rejection is coded `input`, its
920
+ message states that standard-input writing failed, and it carries the host fault as its `cause`;
921
+ with `strict: false`, the result carries no cause member. This behavior is distinct from the quiet
922
+ constructor input phase of `Process`.
923
+
924
+ A run with no `timeout` and no `signal` is unbounded, and what it waits for is stdio completion
925
+ rather than process exit. A descendant that inherits the child's stdio holds those pipes open after
926
+ the child itself has exited, and the run stays pending for as long as the descendant lives. Give
927
+ `execute` a `timeout` wherever the command may start a descendant that inherits its stdio; the bound
928
+ that the later [Where `execute` and `executeSync` differ](#where-execute-and-executesync-differ)
929
+ section describes applies to a terminated run and cannot rescue one that was never bounded.
930
+
931
+ ### The result family
932
+
933
+ `ExecuteResult` reports the outcome through booleans. `failed` is derived from the rest of the
934
+ result, and `expired`, `aborted`, and `truncated` each report one specific thing that happened.
935
+
936
+ | Field | True when |
937
+ | ----------- | -------------------------------------------------------------------------- |
938
+ | `failed` | The run did not complete successfully, whatever ended it. |
939
+ | `expired` | The run's own `timeout` elapsed before completion. |
940
+ | `aborted` | The caller's `signal` aborted the run before completion. |
941
+ | `truncated` | Either stream exceeded `limit`, so the captured text is the retained head. |
942
+
943
+ `failed` is derived: a run failed when it timed out, was aborted, ended on a host fault, was ended by
944
+ a signal, or exited with a code other than `0`. A `null` code from a spawn fault is therefore a
945
+ failure, an abort is a failure, and a synchronous overflow is a failure. `expired` and `aborted` are
946
+ the ways the run ended the child rather than the child ending itself, and only the earliest observed
947
+ is recorded, so they are mutually exclusive. For a `strict: false` caller, `failed: true` with
948
+ `expired`, `aborted`, and `truncated` false, `code: 0`, and `signal: null` is the residual signature
949
+ that a host fault ended the run.
950
+
951
+ A spawn fault reports the host's negative errno in `ProcessExit.code` and an asynchronous
952
+ `ExecuteResult.code`. The synchronous `executeSync` result reports `null` instead.
953
+
954
+ By default a failed run rejects with a `ProcessError` carrying the `ExecuteResult` on its `result`
955
+ property. An expired run carries code `timeout`, and a host fault while writing standard input
956
+ carries code `input`; every other failure carries code `spawn`. Passing `strict: false` settles with
957
+ the result even on failure, so you inspect `failed` yourself.
958
+
959
+ ```ts
960
+ import { execute } from '@orkestrel/process/server'
961
+
962
+ // Rejecting form (the default): a failed run throws.
963
+ const version = await execute({ file: 'node', arguments: ['--version'] })
964
+ version.failed // false
965
+ version.stdout.startsWith('v') // true
966
+
967
+ // Non-rejecting form: inspect the outcome directly.
968
+ const outcome = await execute(
969
+ { file: 'node', arguments: ['-e', 'process.exit(3)'] },
970
+ { strict: false },
971
+ )
972
+ outcome.failed // true
973
+ outcome.code // 3
974
+ ```
975
+
976
+ ### Output bounds
977
+
978
+ Each of `stdout` and `stderr` is capped at `limit` bytes, keeping the captured head and never
979
+ splitting a UTF-8 sequence. `truncated` reports that a cap was reached; `execute` and `executeSync`
980
+ differ in what they do about it.
981
+
982
+ `execute` captures one byte past that bound, so the final trim reads the first excluded byte and
983
+ retreats off a sequence the cut split. `executeSync` hands `limit` to the host as `maxBuffer` and
984
+ arrives at the same place from the other side: a child that overruns that ceiling still returns the
985
+ bytes the host had already read, which reach past `limit`, so the trim has its excluded byte there
986
+ too. Neither function returns a split sequence, and the returned text is bounded by `limit` in each
987
+ case. `captureChunk` applies the per-chunk bound `execute` captures under:
988
+
989
+ ```ts
990
+ import { Buffer } from 'node:buffer'
991
+ import { captureChunk } from '@orkestrel/process/server'
992
+
993
+ captureChunk(Buffer.from('hello'), 3)?.toString('utf8') // 'hel'
994
+ captureChunk('hello', 3) // undefined
995
+ ```
996
+
997
+ One `truncated` flag covers both streams, so a consumer that parses `stdout` structurally cannot
998
+ tell from the result which stream overflowed. Nothing in the result recovers that: both captured
999
+ strings are trimmed to `limit`, so a stream that stopped exactly at the cap and a stream that ran
1000
+ past it read the same length. Where the distinction matters, re-run with a `limit` high enough that
1001
+ `truncated` is `false`, then compare each captured length against the original bound — or supervise
1002
+ the child with `Process`, which bounds `lines` and `evidence` separately.
1003
+
1004
+ `execute` keeps reading past the cap, discards the excess, and reports `truncated` without failing
1005
+ the run. `executeSync` has no such option: the host ends the child with `SIGKILL` when the overflow
1006
+ arrives, so its result reports `truncated` and `failed` together, with the partial output trimmed to
1007
+ `limit`.
1008
+
1009
+ ```ts
1010
+ import { execute, executeSync } from '@orkestrel/process/server'
1011
+
1012
+ const script = 'process.stdout.write("x".repeat(4096))'
1013
+
1014
+ const streamed = await execute(
1015
+ { file: 'node', arguments: ['-e', script] },
1016
+ { limit: 16, strict: false },
1017
+ )
1018
+ streamed.truncated // true
1019
+ streamed.failed // false
1020
+
1021
+ const blocking = executeSync(
1022
+ { file: 'node', arguments: ['-e', script] },
1023
+ { limit: 16, strict: false },
1024
+ )
1025
+ blocking.truncated // true
1026
+ blocking.failed // true
1027
+ blocking.signal // 'SIGKILL'
1028
+ ```
1029
+
1030
+ ### Where `execute` and `executeSync` differ
1031
+
1032
+ `executeSync` is not a synchronous mirror of `execute`. Read the differences before you swap one
1033
+ for the other.
1034
+
1035
+ | Subject | `execute` | `executeSync` |
1036
+ | --------------- | --------------------------------------------------------- | ---------------------------------------------------- |
1037
+ | Cooperative end | `grace` between `SIGTERM` and `SIGKILL` on a POSIX host. | None; a timeout ends the root alone with `SIGKILL`. |
1038
+ | Descendants | A timeout or abort terminates the child tree. | A timeout can leave descendants running. |
1039
+ | Cancellation | An `AbortSignal` terminates the run and sets `aborted`. | None; the host offers no in-flight cancellation. |
1040
+ | Overflow | Reports `truncated` and keeps the run successful. | Reports `truncated` and `failed`, killing the child. |
1041
+ | Spawn fault | Reports the host's negative errno in `code`. | Reports `null` in `code`. |
1042
+ | Refusal | Rejects before spawning, because it is an async function. | Throws before spawning. |
1043
+
1044
+ `execute` and `executeSync` share the rest: the same resolver and no implicit shell, the same
1045
+ environment merge, the same `input` override, the same `limit` bounding, and the same `strict`
1046
+ behavior.
1047
+
1048
+ `execute` also bounds what follows termination. After a timeout or an abort ends the child,
1049
+ `stopChild` runs and the outcome is then awaited for one further `PROCESS_CONFIRMATION`, so a
1050
+ descendant still holding the child's stdio cannot keep a terminated run pending forever. Nothing
1051
+ bounds a run that was never terminated: with no `timeout` and no `signal` there is no deadline to
1052
+ reach, and the run waits on stdio completion for as long as the descendant holds the pipe.
1053
+
1054
+ ## Detached spawns
1055
+
1056
+ `detach` spawns a command and returns immediately. The child owns no stdio, is unreferenced, and
1057
+ outlives this process, so nothing here observes its outcome. Its environment comes from the command's
1058
+ own `environment` and `isolated` rather than from a per-invocation override, and `DetachOptions`
1059
+ carries only the directory the child starts in.
1060
+
1061
+ `detach` returns nothing, not a process id. Fire-and-forget is the whole contract: an id you cannot
1062
+ observe an exit for invites a supervision you would have to build yourself. Use `Process` when you
1063
+ need the pid, the streams, the events, or a bounded stop.
1064
+
1065
+ `detach` validates first, so a malformed working directory or command string throws a `ProcessError`
1066
+ coded `invalid` before anything is spawned. It reads each option once, so the working directory it
1067
+ validated is the one the child starts in. After the spawn, a host fault is swallowed rather than
1068
+ crashing the caller.
1069
+
1070
+ ```ts
1071
+ import { detach } from '@orkestrel/process/server'
1072
+
1073
+ detach({ file: process.execPath, arguments: ['-e', ''] }, { workspace: process.cwd() })
1074
+ ```
1075
+
1076
+ ## The keyed registry
1077
+
1078
+ `ProcessManager` holds live children by id. `launch` spawns a `Process` under an id, registers it,
1079
+ and emits `launch`. A child that settles removes itself from the registry and emits `exit`, so
1080
+ `count` and `processes` reflect only live children, and that eviction needs no polling. `destroy`
1081
+ destroys every child, clears the registry, then destroys the registry emitter last, so `count` is `0`
1082
+ afterwards.
1083
+
1084
+ `destroy` tears each child down rather than only stopping it, so each child's own observation emitter
1085
+ is destroyed too and every subscription on it goes silently inert: a `stderr` or `exit` listener
1086
+ registered on a child stops firing, and nothing reports that it did. The registry emitter is
1087
+ destroyed after all of them. Read a terminal state you still need from the child's `exit` promise,
1088
+ which settles independently of any emitter.
1089
+
1090
+ Eviction follows the child's own `exit` promise, which no listener can forge, so it lands one
1091
+ microtask after the child's public `exit` event. A listener on that event still sees the child
1092
+ registered; a listener on the manager's `exit` event sees it gone.
1093
+
1094
+ ```ts
1095
+ import { createProcessManager } from '@orkestrel/process/server'
1096
+
1097
+ const manager = createProcessManager({
1098
+ on: {
1099
+ launch: (id) => console.log(`launched ${id}`),
1100
+ exit: (id, { code }) => console.log(`${id} exited with code ${String(code)}`),
1101
+ },
1102
+ })
1103
+
1104
+ const child = manager.launch('probe', {
1105
+ command: { file: 'node', arguments: ['--version'] },
1106
+ workspace: process.cwd(),
1107
+ })
1108
+ manager.count // 1
1109
+ manager.process('probe') === child // true
1110
+ manager.processes().length // 1
1111
+
1112
+ await child.exit
1113
+ manager.count // 0 — the settled child evicted itself
1114
+
1115
+ await manager.destroy()
1116
+ ```
1117
+
1118
+ `launch` refuses in these ways, and a spawn fault is none of them: a child that fails to spawn is
1119
+ returned, and its fault surfaces through its own `exit` and `error` event rather than from `launch`.
1120
+
1121
+ - A `duplicate`-coded `ProcessError` when the id is already live.
1122
+ - A `protocol`-coded `ProcessError` after `destroy` has begun. The check runs again after the child
1123
+ exists, because reading an option runs your own code and that code can start the teardown; a
1124
+ registry being destroyed adopts nothing, so the child it has already spawned is destroyed and the
1125
+ launch is still refused. The `protocol` refusal throws synchronously, and the `destroy` barrier
1126
+ covers that child's teardown, so the refused child reaches its terminal moment before the barrier
1127
+ resolves.
1128
+ - An `invalid`-coded `ProcessError` when an option or command string is malformed. The id is
1129
+ reserved before the child is constructed and released when construction throws, so a refused launch
1130
+ strands no key.
1131
+
1132
+ The `stop` overloads terminate by scope. `stop(id)` resolves `true` only when the child was live and
1133
+ its native exit was confirmed, so a not-live id and an unconfirmed termination both resolve `false`.
1134
+ `stop(ids)` resolves `true` only when every named child did. `stop()` with no argument terminates
1135
+ every live child and resolves `void`.
1136
+
1137
+ ## Errors
1138
+
1139
+ `ProcessError` is the one failure type, carrying a stable machine-readable `code`. Narrow a caught
1140
+ value with `isProcessError`, then branch on `code`.
1141
+
1142
+ | Code | Raised when |
1143
+ | ----------- | --------------------------------------------------------------------------------------------------------- |
1144
+ | `spawn` | A rejecting run failed outside its own timeout or input write. |
1145
+ | `timeout` | A rejecting run's own `timeout` elapsed before completion. |
1146
+ | `input` | `execute` alone: a rejecting run's standard-input write reported a host fault. |
1147
+ | `duplicate` | `ProcessManager.launch` reused an id that is already live. |
1148
+ | `protocol` | `ProcessManager.launch` ran after `destroy` began, or a supervised channel's open stdin reported a fault. |
1149
+ | `invalid` | A public input was refused before anything was spawned. |
1150
+
1151
+ `isProcessError` recognizes an error thrown by another installed copy of the package. It reads a
1152
+ global own-property brand rather than `instanceof`, so a duplicate installation and an ESM/CommonJS
1153
+ module copy both narrow, where a prototype check would refuse both. Recognition holds across copies
1154
+ at 0.0.4 or later: a copy earlier than 0.0.4 stamps no brand, so an error it throws stays outside the
1155
+ type. The guard admits exactly the codes `PROCESS_ERROR_CODES` declares, so a code added there is
1156
+ admitted with no further edit.
1157
+
1158
+ A run failure carries its `ExecuteResult` on `error.result`, and its command line, exit `code`, and
1159
+ `signal` on `error.context`. A duplicate-id and a protocol failure carry the offending `id` on
1160
+ `error.context`. A validation failure carries the rejected input on `error.context.value`. The
1161
+ underlying cause, when one exists, is retained on `error.cause`.
1162
+
1163
+ ```ts
1164
+ import { createDuplicateError, createInvalidError, createProtocolError } from '@orkestrel/process'
1165
+
1166
+ createDuplicateError('build').code // 'duplicate'
1167
+ createProtocolError('build').code // 'protocol'
1168
+ createInvalidError("option 'grace'", -1).code // 'invalid'
1169
+ createInvalidError("option 'grace'", -1).context?.value // -1
1170
+ ```
1171
+
1172
+ Every public entry point validates before it spawns. `execute` rejects rather than throwing, because
1173
+ an async function cannot throw synchronously; the `Process` constructor, `executeSync`, and `detach`
1174
+ all throw.
1175
+
1176
+ ```ts
1177
+ import { executeSync } from '@orkestrel/process/server'
1178
+ import { isProcessError } from '@orkestrel/process'
1179
+
1180
+ try {
1181
+ executeSync({ file: 'node', arguments: ['--version'] }, { timeout: -1 })
1182
+ } catch (error) {
1183
+ if (isProcessError(error)) {
1184
+ error.code // 'invalid'
1185
+ error.context?.value // -1
1186
+ }
1187
+ }
1188
+ ```
1189
+
1190
+ `createExecuteError` constructs rejecting run failures other than standard-input write faults, and
1191
+ `buildExecuteResult` assembles the result each error carries.
1192
+
1193
+ ```ts
1194
+ import { buildExecuteResult } from '@orkestrel/process/server'
1195
+ import { createExecuteError } from '@orkestrel/process'
1196
+
1197
+ const result = buildExecuteResult({
1198
+ command: 'node -e process.exit(1)',
1199
+ stdout: new TextEncoder().encode('ok'),
1200
+ stderr: new Uint8Array(0),
1201
+ code: 1,
1202
+ signal: null,
1203
+ expired: false,
1204
+ aborted: false,
1205
+ truncated: false,
1206
+ limit: 1_024,
1207
+ })
1208
+
1209
+ result.failed // true
1210
+ createExecuteError(result).code // 'spawn'
1211
+ createExecuteError(result).result === result // true
1212
+ ```
1213
+
1214
+ ## Observing
1215
+
1216
+ `Process`, `Session`, and `ProcessManager` each expose a typed `emitter` for fire-and-forget
1217
+ observers — logging, metrics, tracing. Subscribe through `child.emitter.on(...)` or
1218
+ `manager.emitter.on(...)`, or wire initial listeners through the `on` option; supply an `error`
1219
+ handler to receive a listener's throw. The `error` handler and the `error` event are distinct: the
1220
+ handler receives a listener's own throw, while the `error` event carries a child or channel fault.
1221
+ Emitting is observation-only: every event fires after the transition it reports, and a throwing
1222
+ listener is isolated and routed to the `error` handler, never onto a domain event, so a faulty
1223
+ observer cannot corrupt the engine.
1224
+
1225
+ | Event map | Events |
1226
+ | ------------------------ | ----------------------------------------------------------------- |
1227
+ | `ProcessEventMap` | `stderr(chunk)` · `error(cause)` · `exit(exit)` |
1228
+ | `SessionEventMap` | `stdout(chunk)` · `stderr(chunk)` · `error(cause)` · `exit(exit)` |
1229
+ | `ProcessManagerEventMap` | `launch(id)` · `exit(id, exit)` |
1230
+
1231
+ A `Process` emits `stderr` for each decoded standard-error chunk, `error` when the child fails to
1232
+ spawn, when the child itself errors, and when the host reports a fault on the standard-input
1233
+ channel after the constructor input phase, and `exit` once, with the terminal `ProcessExit`, when
1234
+ the child settles. A spawn or child fault carries its cause directly; a standard-input fault carries
1235
+ a `ProcessError` coded `protocol` whose `cause` is the host fault. A fault arising from constructor
1236
+ `input` or its closing `end` stays quiet. A spawn fault emits `error` and then still resolves `exit`. A
1237
+ `ProcessManager` emits `launch` when a child joins the registry and `exit`, with the child's id and
1238
+ terminal state, when it settles and leaves.
1239
+
1240
+ A `Session` emits the same `stderr`, `error`, and `exit` moments, and adds `stdout` for each chunk of
1241
+ raw bytes the host delivered. The `stdout` payload is the session's own owned array, so a listener
1242
+ can keep it past the call, and no `stdout` event follows the `exit` event.
1243
+
1244
+ ```ts
1245
+ import { createProcess } from '@orkestrel/process/server'
1246
+
1247
+ const child = createProcess({
1248
+ command: { file: 'node', arguments: ['worker.js'] },
1249
+ workspace: process.cwd(),
1250
+ })
1251
+
1252
+ child.emitter.on('stderr', (chunk) => log.warn(chunk))
1253
+ child.emitter.on('exit', ({ code, signal }) => metrics.record('worker.exit', { code, signal }))
1254
+ ```
1255
+
1256
+ ## Patterns
1257
+
1258
+ ### Collect output in one call
1259
+
1260
+ The fence that follows runs one command to completion and reads its captured standard output.
1261
+
1262
+ ```ts
1263
+ import { execute } from '@orkestrel/process/server'
1264
+
1265
+ const { stdout } = await execute({ file: 'git', arguments: ['rev-parse', 'HEAD'] })
1266
+ const commit = stdout.trim()
1267
+ ```
1268
+
1269
+ ### Stream a long-running child and cancel it
1270
+
1271
+ The fence that follows reads a child line by line and lets an `AbortController` end it.
1272
+
1273
+ ```ts
1274
+ import { createProcess } from '@orkestrel/process/server'
1275
+
1276
+ const controller = new AbortController()
1277
+ const child = createProcess({
1278
+ command: { file: 'node', arguments: ['tail.js'] },
1279
+ workspace: process.cwd(),
1280
+ grace: 1_000,
1281
+ signal: controller.signal,
1282
+ })
1283
+
1284
+ setTimeout(() => controller.abort(), 10_000) // stop after ten seconds
1285
+ for await (const line of child.lines) console.log(line) // ends at the terminal moment
1286
+ await child.exit
1287
+ await child.destroy()
1288
+ ```
1289
+
1290
+ The abort reaches the terminal moment through the same bounded `stop`, so the loop exits on its own
1291
+ and the `exit` promise is already settled when the loop returns.
1292
+
1293
+ ### Close a byte session cooperatively
1294
+
1295
+ The fence that follows ends the session's input, waits out a window of the caller's own, and terminates the child only when that window elapses.
1296
+
1297
+ ```ts
1298
+ import { createSession } from '@orkestrel/process/server'
1299
+
1300
+ const session = createSession({
1301
+ command: { file: 'node', arguments: ['language-server.js', '--stdio'] },
1302
+ workspace: process.cwd(),
1303
+ })
1304
+
1305
+ // …on shutdown: end the input, give the child a window of your own, then terminate if it overruns.
1306
+ const window = new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2_000))
1307
+ const flushed = await Promise.race([session.end().then(() => true), window])
1308
+ const finished = flushed ? await Promise.race([session.ending.then(() => true), window]) : false
1309
+ if (!finished) await session.stop()
1310
+ await session.destroy()
1311
+ ```
1312
+
1313
+ The window covers `end` as well as the child's own exit, because the flush `end` awaits carries no
1314
+ bound: a child that stopped reading its input leaves the accepted bytes in the pipe, and awaiting
1315
+ that barrier bare waits on them indefinitely.
1316
+
1317
+ The window is yours rather than the package's. `grace` bounds the gap between `SIGTERM` and
1318
+ `SIGKILL` once a termination starts, and `drain` bounds the wait for the read ends afterwards;
1319
+ neither one bounds how long you let a child finish work it was already doing.
1320
+
1321
+ ### Supervise a fleet by id
1322
+
1323
+ The fence that follows launches one child per task under its own id and tears the whole registry down at shutdown.
1324
+
1325
+ ```ts
1326
+ import { createProcessManager } from '@orkestrel/process/server'
1327
+
1328
+ const manager = createProcessManager()
1329
+ for (const task of ['lint', 'test', 'build']) {
1330
+ manager.launch(task, {
1331
+ command: { file: 'npm', arguments: ['run', task] },
1332
+ workspace: process.cwd(),
1333
+ })
1334
+ }
1335
+ // …on shutdown, stop everything and tear down:
1336
+ await manager.destroy()
1337
+ ```
1338
+
1339
+ ### Build a bounded stop of your own
1340
+
1341
+ The termination helpers are the pieces `stop` composes, and they are exported so a caller supervising
1342
+ a child it spawned itself gets the same bounded sequence. Reach for `stopChild` first: it is the
1343
+ whole sequence, and it is host-aware. It returns for a child that has already exited before any
1344
+ route to its pid runs, because the host has reaped that number and can have handed it to another
1345
+ process. A descendant whose root exited between your decision and that liveness read is therefore
1346
+ unreachable, on either host; `drain` is what bounds the wait for it.
1347
+
1348
+ ```ts
1349
+ import { spawn } from 'node:child_process'
1350
+ import { stopChild } from '@orkestrel/process/server'
1351
+
1352
+ const worker = spawn(process.execPath, ['-e', 'setInterval(() => undefined, 50)'], {
1353
+ detached: process.platform !== 'win32',
1354
+ stdio: 'ignore',
1355
+ })
1356
+
1357
+ const confirmed = await stopChild(worker, 5_000, 5_000)
1358
+ confirmed // true when the native exit arrived
1359
+ ```
1360
+
1361
+ `stopChild` reports the child's own ending. Pair it with `waitForClose` to reach the supervision's
1362
+ ending over a child you spawned yourself, and register that wait before the termination so a close
1363
+ landing between them is still observed.
1364
+
1365
+ ```ts
1366
+ import { spawn } from 'node:child_process'
1367
+ import { stopChild, waitForClose } from '@orkestrel/process/server'
1368
+
1369
+ const collector = spawn(process.execPath, ['-e', 'setInterval(() => undefined, 50)'], {
1370
+ detached: process.platform !== 'win32',
1371
+ })
1372
+
1373
+ const closing = waitForClose(collector, 1_000) // registered before the termination
1374
+ await stopChild(collector, 5_000, 5_000)
1375
+ const closed = await closing
1376
+ closed // true when the streams closed inside the bound
1377
+ ```
1378
+
1379
+ Drive the pieces yourself only when you want a different sequence — a shorter cooperative window, an
1380
+ extra warning signal, a step of your own between them. Guard each step with `isExited`, and drive a
1381
+ child `stopChild` has not been called on: after the host reuses a dead child's process id, signalling
1382
+ that id reaches its new process.
1383
+
1384
+ ```ts
1385
+ import { spawn } from 'node:child_process'
1386
+ import { isExited, killProcess, killTree, waitForExit } from '@orkestrel/process/server'
1387
+
1388
+ const reporter = spawn(process.execPath, ['-e', 'setInterval(() => undefined, 50)'], {
1389
+ detached: process.platform !== 'win32',
1390
+ stdio: 'ignore',
1391
+ })
1392
+
1393
+ if (!isExited(reporter)) {
1394
+ killProcess(reporter, 'SIGTERM') // POSIX: the whole process group
1395
+ await waitForExit(reporter, 1_000)
1396
+ }
1397
+ if (!isExited(reporter) && process.platform === 'win32') {
1398
+ await killTree(reporter.pid ?? 0, 5_000)
1399
+ }
1400
+ if (!isExited(reporter)) {
1401
+ killProcess(reporter, 'SIGKILL')
1402
+ await waitForExit(reporter, 5_000)
1403
+ }
1404
+ isExited(reporter) // whether the native exit arrived
1405
+ ```
1406
+
1407
+ ### Practices
1408
+
1409
+ - **Set `grace` to the child's real cleanup budget on a POSIX host** — `stop` waits that long after
1410
+ `SIGTERM` before `SIGKILL`, so a child that flushes on shutdown needs enough of a window to finish.
1411
+ Windows has no cooperative phase, so the value does nothing there.
1412
+ - **Read `truncated` rather than assuming captured output is complete** — a `Process` reports a
1413
+ `lines` omission, and a run reports the `limit` it hit; a synchronous run fails on that limit while
1414
+ an asynchronous run does not.
1415
+ - **Raise `backlog` for a chatty child you iterate slowly** — the default holds `PROCESS_BACKLOG`
1416
+ bytes of unconsumed lines before stdout pauses. Pausing keeps that consumer lossless before
1417
+ termination; from the moment a stop begins, retention is capped at twice `backlog`, later lines are
1418
+ dropped without pausing, and `truncated` reports the gap.
1419
+ - **Attach a consumer before the child speaks, or accept the gap** — a `lines` iterator requested
1420
+ after the mark was exceeded receives the retained head, a gap, then the live stream.
1421
+ - **Iterate `lines` once and fan out from that loop** — the stream is single-consumer, so another
1422
+ iterator takes lines away from the one already iterating rather than repeating them.
1423
+ - **Reach for `Session` when the child speaks a protocol** — `lines` frames text, and a
1424
+ length-prefixed header, a binary payload, or a NUL byte survives no line framer. A session hands
1425
+ you the exact bytes and lets you frame them.
1426
+ - **Close a session with `end`, then race `ending`, then `stop`** — `end` closes the input and
1427
+ terminates nothing, `ending` reports the child's own exit, and `stop` is the escalation for a child
1428
+ that overruns the window you gave it. Race `ending` rather than `exit`: `exit` waits out `drain`
1429
+ for a descendant holding the pipe, so a window raced against it escalates against a child that
1430
+ already ended.
1431
+ - **Give `execute` a `timeout` when the command can start a descendant** — an unbounded run waits on
1432
+ stdio completion, and a descendant that inherited the child's pipes holds it open past the child's
1433
+ own exit.
1434
+ - **Derive liveness before you address `pid`** — a live child is
1435
+ `pid !== undefined && code === null && signal === null`, and the host reuses a dead child's id, so
1436
+ a signal sent without that check reaches whatever process holds the id.
1437
+ - **Read `evidence` on a failed exit** — the byte-bounded stderr tail is the diagnostic to attach,
1438
+ bounded by `evidence` (default `PROCESS_EVIDENCE`). It freezes at the terminal moment, so read it
1439
+ after `exit` settles and keep no copy of your own.
1440
+ - **Read `drained` before you treat the diagnostics as complete** — `true` reports that the child's
1441
+ streams closed, so the diagnostics are everything the child wrote. `false` reports that the `drain`
1442
+ bound cut them off, and nothing can report whether more existed, because a descendant holding an
1443
+ inherited pipe is beyond the tree kill and the host counts no remaining writers.
1444
+ - **Lower `drain` for a shutdown with a deadline of its own** — the default `PROCESS_DRAIN` bounds
1445
+ the wait for a descendant that never releases the pipe. Pass `drain: 0` to cut the streams off as
1446
+ soon as the bound is armed.
1447
+ - **Observe, do not drive** — subscribe to `emitter` for lifecycle moments; emitting is a pure
1448
+ side-channel, so a listener never changes what the engine does.
1449
+
1450
+ ## Vocabulary
1451
+
1452
+ Each name on this surface that reads against a house rule is settled here rather than rediscovered.
1453
+
1454
+ | Name | Ruling |
1455
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1456
+ | `execute`, `executeSync` | Use the fixed lifecycle verb for primary work to completion. `executeSync` keeps the ecosystem `Sync` suffix. |
1457
+ | `detach` | Retained as a bare verb where the standalone-helper default reads `{verb}{Noun}`. The call site is unmistakable without the noun: `detach` takes a `ProcessCommand` and its own `DetachOptions`, and it is the one word for the spawn that is not awaited. `detachProcess` was refused for repeating the type the argument already carries. |
1458
+ | `process`, `processes` | Retained. A registry exposes its contents as accessors named for what they hold, so the manager reads `manager.process(id)` and `manager.processes()`. |
1459
+ | `strict` | Replaces `reject`. A boolean reads as an adjective asserting a state, and `reject` named the reaction rather than the mode. `strict: false` resolves with the failed result. |
1460
+ | `evidence`, `backlog` | Byte bounds are named for their subject where an entity has several, so a `Process` carries `evidence` and `backlog` rather than flavours of `limit`. A run has one bound, so it is named for the bound: `limit`. |
1461
+ | `truncated` | One name on both surfaces, because it reports one fact: the surface omitted output. Each entity names its own bound — a `Process` omits `lines` past a retention bound, and an `ExecuteResult` omits captured text past `limit`. |
1462
+ | `run` | Kept as the English noun for one invocation — a terminated run, a run that stays pending. It never names a function; `execute` and `executeSync` are named by their identifiers, so the concept carries one term. |
1463
+ | `settled` | Derives literally: it is `true` exactly when `exit` has settled. `closed` was refused because it borrows a Node event name into `ProcessInterface`, which is host-independent enough to type `signal` as a `string`. |
1464
+ | `stopping` | A present participle for a latched fact, documented as monotonic rather than renamed. It reports that a termination was initiated, not that one is in flight, because the initiation is what a consumer acts on: a child that was asked to end is not a child to send new work to. |
1465
+ | `drain`, `drained` | The option names the window and the result names its outcome, so one concept carries one term across the option and the result. `drain: 0` is an immediate cutoff rather than a disabled bound, unlike the sibling `delivery`, because an unbounded drain is the defect the option prevents. |
1466
+ | `Session` | A second entity rather than a byte mode on `Process`, because a mode would falsify `lines`, `truncated`, and `backlog` on half the instances of one class. `Child` collides with the published `ProcessChildInterface` contract, `Channel` is this package's word for the stdin pipe, and `Stream` and `Duplex` borrow Node class names into contracts typed to stay host-independent. |
1467
+ | `Supervisor` | The spawn, capture, channel, and termination engine `Process` and `Session` compose. It is barrelled because its constructor takes a `ProcessOptions` and a `SupervisorFace`, and a consumer holds both, so a consumer composing a face of its own reaches the same engine `Process` and `Session` do. |
1468
+ | `SupervisorFace` | The callback record a face hands the engine at construction, not a face and not the `Supervisor`'s own face. It carries `Face` rather than the `{Entity}Hooks` form `EmitterHooks` uses, because hooks are optional listeners on an entity that runs without them, while every callback here is a moment the engine must deliver. `{Entity}Interface` was refused because the type declares no behavior of its own: each member holds a function the composing face supplies. It is published because `types.ts` declares it and the server barrel star-exports that module. |
1469
+ | `ending`, `exit` | The endings are named apart, because a transport acts on each differently. `ending` is the child's own exit and resolves no value, because `code` and `signal` already carry the facts and a second copy could only drift. `exit` stays on the terminal moment, so `exit`, `settled`, and the `exit` event name one moment on both faces. |
1470
+ | `end` | The consistency class of `destroy`: an idempotent lifecycle member returning the barrier every call shares. `close` was refused for borrowing a Node event name, the reason `settled` already records. It resolves `void` because every fact a result could carry is derivable — a later `write` reports `false`, and `ending` reports the exit. |
1471
+ | `write`, `send` | Different verbs because they promise different things. `send` frames a line and appends the terminator; `write` puts the exact bytes on the channel and appends nothing. One name over both would hide the terminator at the call site, which is the defect the split prevents. |
1472
+ | `stdout`, `stderr` | One face decodes one stream and not the other, because they are read differently. Standard output is a payload a parser consumes, so it stays bytes; standard error is a diagnostic a person reads, so it is decoded, and `evidence` bounds those same bytes. |
1473
+ | `backlog`, `writable` | Omitted from `SessionOptions` rather than falsified on it. A session retains no lines, so no backlog bound applies; its channel is open until `end` closes it, so no switch selects whether one exists. An option that could only ever hold one value is a member a consumer must learn and can never use. |
1474
+
1475
+ ## Tests
1476
+
1477
+ Every proof that starts a real child runs in the `src:server` project, because spawning is this
1478
+ package's server subject. Such a proof is an expensive one, and the fixed isolated projects carry
1479
+ different subjects: the `distribution` project proves what the packed artifact installs, and the
1480
+ `service` project proves a live external service. This package drives no external service, so it
1481
+ declares no `service` project at all. Filing a spawn proof under either subject moves it out of the
1482
+ default gate, and the package's own behavior then goes unproven until a publish.
1483
+
1484
+ Size every budget in a spawning suite — a case timeout, a termination wait, a condition budget —
1485
+ from a full contended run rather than from an isolated one. Those suites start real children
1486
+ concurrently, so each case pays for the children every other file starts beside it. On Linux with
1487
+ Node v22.22.2 on 2026-08-25, `npm run test:src` reported a 6.94s wall duration over 12.86s of
1488
+ aggregate test time, while the `tests/src/server/processes/ProcessManager.test.ts` file alone
1489
+ reported 1.97s.
1490
+ A budget sized from the isolated cost turns that contention into a red gate reporting a timeout, and
1491
+ a timeout carries no diagnostic about the code.
1492
+
1493
+ The pure platform-decision rows execute both `win32` and POSIX inputs on every host. They cover
1494
+ environment-key folding and merging, `PATHEXT` candidate order, batch routing, argument quoting, and
1495
+ the percent-sign refusal. The 2026-08-20 Linux reading predates the candidate-order correction.
1496
+ The corrected candidate-order row has a Windows reading on 2026-09-13 and no Linux reading.
1497
+ The live POSIX rows were proven on Linux on 2026-08-20, before the terminal-moment fixtures landed.
1498
+
1499
+ The live Windows filesystem, `cmd.exe`, and `taskkill.exe` rows execute on Windows only. The
1500
+ 2026-08-21 Windows run settled `killTree` through `taskkill.exe` and grandchild tree termination
1501
+ through a live root. On Windows 11 with Node v24.20.0 on 2026-09-13, the selected resolution run
1502
+ settled the `PATHEXT` candidates and the real sibling `.cmd` launch. This command produced that
1503
+ reading:
1504
+
1505
+ ```text
1506
+ npm run test:src:server -- tests/src/server/helpers.test.ts -t "Windows extensionless candidates|Windows sibling launchers"
1507
+ ```
1508
+
1509
+ The unproven residue is the live POSIX rows against the same terminal-moment fixtures,
1510
+ which cover the terminal moment, the drain cutoff, and the descendant that outlives its root. On a
1511
+ POSIX host, settle them and re-run every server row with this command:
1512
+
1513
+ ```text
1514
+ npx vitest run --config vite.config.ts --no-cache --project src:server
1515
+ ```
1516
+
1517
+ The standard-input fault rows execute on every host, and only their Windows reading has been taken,
1518
+ on 2026-08-21. A write still pending when the child exits reports the host's `EOF` there and `EPIPE`
1519
+ on POSIX, and both arrive through the same `protocol` error, so the rows assert that shape rather
1520
+ than the errno. The POSIX `EPIPE` fast path is therefore the unproven residue, alongside the delivery
1521
+ matrix and the line framing across the supported Node lines. A POSIX child that closes its own file
1522
+ descriptor 0 is also expected to fault where the measured Windows child does not. On a POSIX host,
1523
+ settle each with the same command.
1524
+
1525
+ The pure decision rows do not prove Windows end to end. They prove the decisions.
1526
+
1527
+ - [`tests/src/core/errors.test.ts`](../tests/src/core/errors.test.ts) — the error surface:
1528
+ `isProcessError` narrowing its own error and refusing a plain `Error`, the codes the guard admits
1529
+ compared against the declared `PROCESS_ERROR_CODES` tuple with a refusal control drawn from
1530
+ outside it, and recognition of an error constructed by another source copy of the module.
1531
+ - [`tests/src/server/processes/Process.test.ts`](../tests/src/server/processes/Process.test.ts) —
1532
+ the supervised child:
1533
+ line framing across every terminator, a split CRLF pair, a carriage-return redraw, and a trailing
1534
+ partial line, the bounded backlog under each consumer policy and under a flood of empty lines, the
1535
+ byte-bounded `evidence` tail and live `stderr` event, `send` over an open and a closed channel, the
1536
+ `delivery` bound against its unbounded control, the `protocol` fault a host-reported channel
1537
+ failure raises beside the silence a package-initiated teardown keeps, bounded termination and its
1538
+ confirmation, the POSIX escalation from a trapped `SIGTERM` to `SIGKILL`, abort-signal termination,
1539
+ the isolated environment, the `invalid` refusals, and `destroy`. The terminal moment carries its
1540
+ own rows: the frozen `evidence` tail read against a descendant that keeps writing, the `stderr`
1541
+ event and the tail stopping together, `lines` ending an in-flight read after its queued lines, the
1542
+ `exit` promise settling at the cutoff when the streams never close, the `drain` bound driven shorter
1543
+ and longer than a descendant release, `stop` alone reaching the moment with no `destroy` call, the
1544
+ latched `stopping` refusing a `send`, the released abort listener, the spawn-fault path, and the
1545
+ `drain` refusals at each end of its range.
1546
+ - [`tests/src/server/processes/Session.test.ts`](../tests/src/server/processes/Session.test.ts) —
1547
+ the byte face: a binary payload carrying NUL bytes, an invalid UTF-8 sequence, a lone carriage return, and an embedded line
1548
+ feed arriving byte-identical in one event, a half-megabyte stream reassembled byte for byte, and
1549
+ each emitted chunk read as a plain owned array against a raw spawn of the same child as the
1550
+ control. `write` echoing its exact bytes with no terminator added, refused after `end`, inside a
1551
+ `stop`, and after the child settles, bounded by `delivery` against an unbounded control, settled
1552
+ `false` by teardown with no event, and raising one `protocol` error on a host-reported channel
1553
+ fault. `end` leaving the child running against a reading child as the control, sharing one barrier,
1554
+ carrying a self-exiting child to each ending with no `stop` call, escalating to `stop` when the
1555
+ child overruns, keeping an ended channel quiet when a pending write later faults, and changing
1556
+ nothing after a `stop`. The endings pulled apart by a descendant holding the pipe, the `exit` event
1557
+ and promise agreeing once, the pid and the frozen `evidence` tail beside the live `stderr` chunks,
1558
+ the spawn-fault path, and the `invalid` refusals.
1559
+ - [`tests/src/server/processes/Supervisor.test.ts`](../tests/src/server/processes/Supervisor.test.ts)
1560
+ — the engine driven through a literal face: the moment order that ends the face's read pipeline, freezes the
1561
+ terminal state, and only then releases the face; the backpressure release reaching a face holding
1562
+ a paused stdout before the termination sequence rather than after it; `ending` settling at the
1563
+ native exit while `exit` waits out the drain a descendant holds open; a `deliver` refused once a
1564
+ termination has begun; and the one barrier every `end` call shares, with the child ending itself
1565
+ because its input ended.
1566
+ - [`tests/src/server/factories.test.ts`](../tests/src/server/factories.test.ts) — the
1567
+ interface-oriented constructors: each `create*` return carrying every member its interface
1568
+ declares, the construction options reaching the entity's own command and emitter rather than
1569
+ stopping at the factory, and the `backlog` refusal proven to precede the spawn against a control
1570
+ child whose marker dates one.
1571
+ - [`tests/src/server/processes/ProcessManager.test.ts`](../tests/src/server/processes/ProcessManager.test.ts)
1572
+ — the registry: `launch` registration and its `duplicate`, `protocol`, and `invalid` refusals, including
1573
+ a teardown started from inside the caller's own option getter, the terminal moment of the child
1574
+ that refusal spawned arriving before the barrier resolves, the eviction of a child whose
1575
+ descendant holds the pipe at the drain cutoff, the unforgeable eviction and its ordering, the
1576
+ query surface, the `stop` overloads, and emitter-last `destroy`.
1577
+ - [`tests/src/server/cloners.test.ts`](../tests/src/server/cloners.test.ts) — the command
1578
+ snapshot: each property read exactly once through a caller's own getter, the frozen argument
1579
+ vector and environment record a later mutation cannot reach, and the absent optional that stays
1580
+ absent rather than becoming an explicit `undefined`.
1581
+ - [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) — the building blocks
1582
+ and the spawns that compose them: `PATHEXT` candidates for extensionless names, the literal path
1583
+ before `PATHEXT` candidates for extension-bearing names, the real Windows sibling `.cmd` selected
1584
+ across bare, relative, and absolute inputs and run through `executeSync` and `execute`, unresolved
1585
+ command-file preservation through `buildSpawn`, each platform input to the quoted batch builder
1586
+ and its percent-sign refusal, the environment merge under each platform input, the UTF-8-safe
1587
+ byte bounds retreating a cut to a code-point boundary, the per-chunk capture bound and the byte it
1588
+ keeps past `limit`, the validators, the termination helpers, and `waitForClose` across a close
1589
+ inside its deadline, a deadline that elapsed first, and the listeners it leaves behind. The runs
1590
+ carry their own rows: the asynchronous one-shot run's owned inputs, buffered outcomes, failure
1591
+ delivery, cancellation, timeout, capture bounds, spawn faults, and pre-spawn refusal; the blocking
1592
+ run's root-only timeout and argument integrity beside its own owned inputs, buffered outcomes,
1593
+ failure delivery, capture bounds, spawn faults, and pre-spawn refusal; and the fire-and-forget
1594
+ spawn's owned inputs, detached process-group behavior, invalid-input refusal, and the validated
1595
+ working directory.
1596
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — this guide: the `## Surface` bijection against
1597
+ each published face's barrel, the interface-to-class method bijections, and the equality gate:
1598
+ every `Summary` cell against its declaration's description paragraph, the titled
1599
+ `Supervise a child and read its lines` fence against the `@example` block of that title (pinned so
1600
+ the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It
1601
+ also runs the flagship fences and asserts the values their comments claim.
1602
+ - [`tests/distribution.test.ts`](../tests/distribution.test.ts) — the artifact a consumer installs:
1603
+ it packs the package, installs the tarball into a directory outside this repository, and compares
1604
+ the runtime exports of each built format against the declarations the compiler parses, under each
1605
+ supported `moduleResolution` mode.
1606
+ - [`tests/setup.test.ts`](../tests/setup.test.ts) — `resolveChildFixture` and `childCommand`, the
1607
+ fixture command builders this suite spawns through: where the fixture resolves, and the argument
1608
+ vector each mode produces.
1609
+ - [`tests/setupServer.test.ts`](../tests/setupServer.test.ts) — the same builders spawned for real:
1610
+ the fixture's own exit code, stdout, and stderr for a supplied detail, its own default when the
1611
+ caller omits one, and the argument vector reaching it unmodified.
1612
+
1613
+ ## See also
1614
+
1615
+ - [`@orkestrel/emitter`](https://github.com/orkestrel/emitter#readme) — the typed push-observation
1616
+ primitive each `emitter` is built on.
1617
+ - [`@orkestrel/contract`](https://github.com/orkestrel/contract#readme) — the guard primitive
1618
+ `isProcessError` composes.
1619
+ - [`AGENTS.md`](../AGENTS.md) — the repository coding, naming, and lifecycle rules.
1620
+ - [`README.md`](README.md) — the guides index.