@orkestrel/process 0.0.7 → 0.0.9

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.
@@ -7,8 +7,8 @@ let node_process = require("node:process");
7
7
  let _orkestrel_contract = require("@orkestrel/contract");
8
8
  let _src_core = require("../core/index.cjs");
9
9
  let node_readline = require("node:readline");
10
- let node_string_decoder = require("node:string_decoder");
11
10
  let _orkestrel_emitter = require("@orkestrel/emitter");
11
+ let node_string_decoder = require("node:string_decoder");
12
12
  //#region src/server/helpers.ts
13
13
  /**
14
14
  * Trims a buffer to at most `limit` trailing bytes without splitting a UTF-8 sequence.
@@ -42,9 +42,12 @@ function trimTail(bytes, limit) {
42
42
  * Trims a buffer to at most `limit` leading bytes without splitting a UTF-8 sequence.
43
43
  *
44
44
  * @remarks
45
- * Keeps the head — the captured start of a one-shot run's output. When the cut point lands inside
46
- * a multibyte sequence, the end retreats to the sequence's start so the retained bytes always end
47
- * on a code-point boundary and decode without a replacement character.
45
+ * Keeps the head — the captured start of a one-shot run's output. The end retreats only when a
46
+ * sequence genuinely spans the cut: the byte at `limit` carries the continuation bit pattern, and a
47
+ * lead byte within the preceding three bytes declares a length that reaches it. The retained bytes
48
+ * then end on a code-point boundary and decode without a replacement character. A byte at `limit`
49
+ * that carries the continuation pattern without such a lead byte is invalid UTF-8 rather than a
50
+ * split sequence, so the full `limit` survives instead of a valid byte being dropped for it.
48
51
  *
49
52
  * @param bytes - The accumulated bytes
50
53
  * @param limit - The maximum retained byte count
@@ -58,13 +61,50 @@ function trimTail(bytes, limit) {
58
61
  function trimHead(bytes, limit) {
59
62
  const buffer = node_buffer.Buffer.isBuffer(bytes) ? bytes : node_buffer.Buffer.from(bytes);
60
63
  if (buffer.byteLength <= limit) return buffer;
61
- let end = limit;
62
- while (end > 0) {
63
- const byte = buffer[end];
64
- if (byte === void 0 || (byte & 192) !== 128) break;
65
- end -= 1;
64
+ const excluded = buffer[limit];
65
+ if (excluded === void 0 || (excluded & 192) !== 128) return node_buffer.Buffer.from(buffer.subarray(0, limit));
66
+ const floor = Math.max(0, limit - 3);
67
+ let start = limit - 1;
68
+ while (start >= floor) {
69
+ const lead = buffer[start];
70
+ if (lead !== void 0 && (lead & 192) !== 128) {
71
+ let span = 1;
72
+ if ((lead & 224) === 192) span = 2;
73
+ else if ((lead & 240) === 224) span = 3;
74
+ else if ((lead & 248) === 240) span = 4;
75
+ if (start + span > limit) return node_buffer.Buffer.from(buffer.subarray(0, start));
76
+ break;
77
+ }
78
+ start -= 1;
66
79
  }
67
- return node_buffer.Buffer.from(buffer.subarray(0, end));
80
+ return node_buffer.Buffer.from(buffer.subarray(0, limit));
81
+ }
82
+ /**
83
+ * Bounds one delivered stream chunk to the bytes a capture still has room for.
84
+ *
85
+ * @remarks
86
+ * A chunk that is not a buffer contributes nothing and reports `undefined` rather than throwing,
87
+ * because a stream `data` listener receives an `unknown` payload. The cut is byte-exact and might
88
+ * land inside a multibyte sequence, because {@link buildExecuteResult} performs the single
89
+ * code-point-boundary trim over the whole capture rather than over each chunk. Give the capture one
90
+ * byte more room than its limit, so that final trim can read the first excluded byte and retreat off
91
+ * a split sequence. A chunk that fits its room is returned as the delivered buffer itself rather
92
+ * than a copy, because a capture appending it copies again and never writes through it; a chunk that
93
+ * overruns its room returns a copy of the bytes that fit. Treat the return as read-only either way.
94
+ *
95
+ * @param chunk - The delivered chunk, ignored when it is not a buffer
96
+ * @param room - The bytes the capture still accepts
97
+ * @returns The bytes to retain, or `undefined` when the chunk contributes none
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * captureChunk(Buffer.from('hello'), 3) // <Buffer 68 65 6c>
102
+ * ```
103
+ */
104
+ function captureChunk(chunk, room) {
105
+ if (!node_buffer.Buffer.isBuffer(chunk) || room <= 0) return void 0;
106
+ if (chunk.byteLength <= room) return chunk;
107
+ return node_buffer.Buffer.from(chunk.subarray(0, room));
68
108
  }
69
109
  /**
70
110
  * Takes one owned frozen snapshot of a caller's command.
@@ -692,135 +732,424 @@ function buildExecuteResult(input) {
692
732
  truncated: input.truncated
693
733
  });
694
734
  }
695
- //#endregion
696
- //#region src/server/Process.ts
697
735
  /**
698
- * Supervises one child while keeping every observation channel aligned at termination.
736
+ * Runs one command to completion, buffering its output, and settles with the outcome.
699
737
  *
700
738
  * @remarks
701
- * The child's ending and the supervision's ending are distinct. `pid`, `code`, and `signal` read
702
- * the host child directly, so they expose native exit as soon as the host records it. `settled`,
703
- * `exit`, `evidence`, and `lines` reach one terminal moment after the read channels close or the
704
- * bounded `drain` window cuts them off. That window is armed by the child's native exit and by a
705
- * termination this package initiated, so every way a child can end reaches the moment. The terminal
706
- * routine freezes `evidence` and ends `lines` before it destroys stdout, stderr, or the emitter, so
707
- * the push channel never disappears while a pull channel can still change or wait indefinitely.
739
+ * The executable is resolved through {@link buildSpawn}, so no run uses a shell. On a POSIX host the
740
+ * child is detached, which is what lets its whole process group be terminated. Standard output and
741
+ * error are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}) and `truncated` reports
742
+ * whether either stream exceeded it. Each capture keeps one byte beyond `limit`, so the single
743
+ * code-point-boundary trim in {@link buildExecuteResult} reads the first excluded byte and never
744
+ * delivers a split multibyte sequence. A positive `timeout` and an aborting `signal` both terminate the
745
+ * child through the same bounded stop, and only the earliest is recorded: a timeout reports
746
+ * `expired` and an abort reports `aborted`, never both. After termination the outcome is awaited for
747
+ * a bounded window, so a descendant holding the child's stdio cannot keep the run pending. That bound
748
+ * covers a terminated run alone: a run with no `timeout` and no `signal` settles on stdio completion
749
+ * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
750
+ * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
751
+ * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
752
+ * overrides `command.input`. A host fault while writing that input terminates the run by design and
753
+ * marks its result failed; a strict rejection carries the host fault as its cause. This differs
754
+ * from `Process` constructor input, whose package-initiated input phase stays quiet. Unless `strict`
755
+ * is `false`, a failed run rejects with a {@link ProcessError} carrying the {@link ExecuteResult};
756
+ * {@link createExecuteError} constructs every rejection outside the input-fault door. An invalid
757
+ * option or command string rejects before the child is spawned, because an async function cannot
758
+ * throw synchronously.
708
759
  *
709
- * Standard output is framed through `readline` into a single-consumer stream. Lines already framed
710
- * and queued remain available through the terminal moment, while bytes arriving after a requested
711
- * termination may be cut off. Standard error is forwarded through the live `stderr` event and
712
- * retained as a byte-bounded raw tail until that same moment. `stop` ends the child tree and the
713
- * observation surfaces. `destroy` performs that stop and then destroys the emitter after the
714
- * frozen terminal state exists.
760
+ * @param command - The executable, arguments, and optional environment and input
761
+ * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
762
+ * @returns The settled run outcome
763
+ * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
715
764
  *
716
765
  * @example
717
766
  * ```ts
718
- * import { Process } from '@orkestrel/process/server'
719
- *
720
- * const child = new Process({
721
- * command: { file: 'node', arguments: ['--version'] },
722
- * workspace: process.cwd(),
723
- * })
724
- * const exit = await child.exit
725
- * await child.destroy()
767
+ * const result = await execute({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
726
768
  * ```
727
769
  */
728
- var Process = class {
729
- #emitter;
730
- #child;
731
- #reader;
732
- #grace;
733
- #drain;
734
- #evidence;
735
- #backlog;
736
- #delivery;
737
- #decoder = new node_string_decoder.StringDecoder("utf8");
738
- #exit = Promise.withResolvers();
739
- #lines;
740
- #queue = [];
741
- #waiters = [];
742
- #signal;
743
- #abort;
744
- #writes = /* @__PURE__ */ new Map();
745
- #tail = node_buffer.Buffer.alloc(0);
746
- #head = 0;
747
- #pending = 0;
748
- #requested = false;
749
- #paused = false;
750
- #truncated = false;
751
- #stopping = false;
752
- #settled = false;
753
- #ended = false;
754
- #input = 0;
755
- #failure;
756
- #waiting;
757
- #termination;
758
- #ending;
759
- /**
760
- * Spawn one child process and begin stream capture.
761
- *
762
- * @param options - Command, workspace, termination, capture, stdin, and observation settings
763
- * @throws A {@link ProcessError} coded `invalid` when an option or command string is malformed
764
- */
765
- constructor(options) {
766
- const source = options.command;
767
- const workspace = options.workspace;
768
- const grace = options.grace;
769
- const drain = options.drain;
770
- const evidence = options.evidence;
771
- const backlog = options.backlog;
772
- const delivery = options.delivery;
773
- const writable = options.writable;
774
- const signal = options.signal;
775
- const on = options.on;
776
- const error = options.error;
777
- const command = snapshotCommand(source);
778
- const input = command.input;
779
- validateCommand(command);
780
- validateWorkspace(workspace);
781
- validateTimer(grace, "option 'grace'");
782
- validateTimer(drain, "option 'drain'");
783
- validateTimer(delivery, "option 'delivery'");
784
- validateBytes(evidence, "option 'evidence'", 0);
785
- validateBytes(backlog, "option 'backlog'", 1);
786
- this.#emitter = new _orkestrel_emitter.Emitter({
787
- ...on === void 0 ? {} : { on },
788
- ...error === void 0 ? {} : { error }
789
- });
790
- this.#grace = grace ?? _src_core.PROCESS_GRACE;
791
- this.#drain = drain ?? _src_core.PROCESS_DRAIN;
792
- this.#evidence = evidence ?? _src_core.PROCESS_EVIDENCE;
793
- this.#backlog = backlog ?? _src_core.PROCESS_BACKLOG;
794
- this.#delivery = delivery ?? 0;
795
- this.#signal = signal;
796
- this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
797
- const childEnvironment = mergeEnvironment(command.isolated === true, command.environment);
798
- const plan = buildSpawn(command, {
799
- workspace,
800
- environment: childEnvironment
770
+ async function execute(command, options) {
771
+ const snapshot = snapshotCommand(command);
772
+ const optionEnvironment = options?.environment;
773
+ const optionWorkspace = options?.workspace;
774
+ const optionInput = options?.input;
775
+ const optionTimeout = options?.timeout;
776
+ const optionGrace = options?.grace;
777
+ const optionSignal = options?.signal;
778
+ const optionStrict = options?.strict;
779
+ const optionLimit = options?.limit;
780
+ validateCommand(snapshot);
781
+ validateEnvironment(optionEnvironment);
782
+ validateWorkspace(optionWorkspace);
783
+ validateTimer(optionTimeout, "option 'timeout'");
784
+ validateTimer(optionGrace, "option 'grace'");
785
+ validateBytes(optionLimit, "option 'limit'", 0);
786
+ const limit = optionLimit ?? _src_core.PROCESS_OUTPUT;
787
+ const grace = optionGrace ?? _src_core.PROCESS_GRACE;
788
+ const timeout = optionTimeout ?? 0;
789
+ const strict = optionStrict ?? true;
790
+ const text = optionInput ?? snapshot.input;
791
+ const line = formatCommand(snapshot);
792
+ const workspace = optionWorkspace ?? process.cwd();
793
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
794
+ const plan = buildSpawn(snapshot, {
795
+ workspace,
796
+ environment
797
+ });
798
+ const settled = Promise.withResolvers();
799
+ const terminate = new AbortController();
800
+ const cleanup = new AbortController();
801
+ const finish = new AbortController();
802
+ const inputFailure = new AbortController();
803
+ const outChunks = [];
804
+ const errChunks = [];
805
+ let spawned = false;
806
+ let expired = false;
807
+ let aborted = false;
808
+ let outRetained = 0;
809
+ let errRetained = 0;
810
+ let cause;
811
+ let timeoutTimer;
812
+ let confirmTimer;
813
+ const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
814
+ cwd: workspace,
815
+ detached: process.platform !== "win32",
816
+ env: environment,
817
+ stdio: [
818
+ "pipe",
819
+ "pipe",
820
+ "pipe"
821
+ ],
822
+ windowsHide: true,
823
+ windowsVerbatimArguments: plan.verbatim
824
+ });
825
+ finish.signal.addEventListener("abort", () => {
826
+ clearTimeout(timeoutTimer);
827
+ clearTimeout(confirmTimer);
828
+ cleanup.abort();
829
+ settled.resolve(buildExecuteResult({
830
+ command: line,
831
+ stdout: node_buffer.Buffer.concat(outChunks),
832
+ stderr: node_buffer.Buffer.concat(errChunks),
833
+ code: child.exitCode,
834
+ signal: child.signalCode,
835
+ expired,
836
+ aborted,
837
+ truncated: outRetained > limit || errRetained > limit,
838
+ limit,
839
+ ...cause === void 0 ? {} : { cause }
840
+ }));
841
+ }, { once: true });
842
+ terminate.signal.addEventListener("abort", () => {
843
+ if (finish.signal.aborted) return;
844
+ clearTimeout(timeoutTimer);
845
+ cleanup.abort();
846
+ stopChild(child, grace, _src_core.PROCESS_CONFIRMATION).then(() => {
847
+ if (finish.signal.aborted) return;
848
+ confirmTimer = setTimeout(() => finish.abort(), _src_core.PROCESS_CONFIRMATION);
801
849
  });
802
- this.#child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
803
- cwd: workspace,
804
- detached: process.platform !== "win32",
805
- env: childEnvironment,
806
- stdio: [
807
- "pipe",
808
- "pipe",
809
- "pipe"
810
- ],
811
- windowsHide: true,
812
- windowsVerbatimArguments: plan.verbatim
850
+ }, { once: true });
851
+ inputFailure.signal.addEventListener("abort", () => {
852
+ if (finish.signal.aborted) return;
853
+ cause = inputFailure.signal.reason;
854
+ terminate.abort();
855
+ }, { once: true });
856
+ child.stdin.on("error", (error) => inputFailure.abort(error));
857
+ child.stdout.on("data", (chunk) => {
858
+ const captured = captureChunk(chunk, limit + 1 - outRetained);
859
+ if (captured === void 0) return;
860
+ outRetained += captured.byteLength;
861
+ outChunks.push(captured);
862
+ });
863
+ child.stderr.on("data", (chunk) => {
864
+ const captured = captureChunk(chunk, limit + 1 - errRetained);
865
+ if (captured === void 0) return;
866
+ errRetained += captured.byteLength;
867
+ errChunks.push(captured);
868
+ });
869
+ child.once("spawn", () => {
870
+ spawned = true;
871
+ });
872
+ child.once("error", (error) => {
873
+ if (spawned) return;
874
+ cause = error;
875
+ finish.abort();
876
+ });
877
+ child.once("close", () => finish.abort());
878
+ if (text !== void 0) child.stdin.write(text, (error) => {
879
+ if (error !== void 0 && error !== null) inputFailure.abort(error);
880
+ });
881
+ child.stdin.end();
882
+ if (timeout > 0) timeoutTimer = setTimeout(() => {
883
+ expired = true;
884
+ terminate.abort();
885
+ }, timeout);
886
+ if (optionSignal !== void 0) {
887
+ optionSignal.addEventListener("abort", () => {
888
+ aborted = true;
889
+ terminate.abort();
890
+ }, {
891
+ once: true,
892
+ signal: cleanup.signal
813
893
  });
814
- this.#reader = (0, node_readline.createInterface)({
815
- input: this.#child.stdout,
816
- crlfDelay: Infinity
894
+ if (optionSignal.aborted) {
895
+ aborted = true;
896
+ terminate.abort();
897
+ }
898
+ }
899
+ const result = await settled.promise;
900
+ if (result.failed && strict) {
901
+ if (cause !== void 0 && cause === inputFailure.signal.reason) throw new _src_core.ProcessError(`Command '${result.command}' failed while writing standard input`, {
902
+ code: "input",
903
+ context: {
904
+ command: result.command,
905
+ code: result.code,
906
+ signal: result.signal
907
+ },
908
+ cause: inputFailure.signal.reason,
909
+ result
817
910
  });
818
- this.#reader.on("line", this.#push.bind(this));
819
- this.#reader.once("close", this.#finish.bind(this));
820
- this.#child.once("error", (cause) => this.#emitter.emit("error", cause));
911
+ throw (0, _src_core.createExecuteError)(result, cause);
912
+ }
913
+ return result;
914
+ }
915
+ /**
916
+ * Runs one command to completion synchronously, buffering its output, and returns the outcome.
917
+ *
918
+ * @remarks
919
+ * The synchronous counterpart of {@link execute}, spawned through the same resolver and never through a
920
+ * shell. The host offers no cooperative termination window and no in-flight cancellation, so this
921
+ * contract carries neither. A positive `timeout` ends only the root process and can leave
922
+ * descendants running; use {@link execute} or {@link Process} when timeout must terminate the tree. A
923
+ * timeout and an output overflow both end the root with `SIGKILL`: an overflow reports `truncated`
924
+ * and `failed` together and trims the partial output to `limit`, where {@link execute} keeps reading and
925
+ * reports `truncated` without failing. The environment and input follow the same merge as
926
+ * {@link execute}. Unless `strict` is `false`, a failed run throws a {@link createExecuteError}
927
+ * carrying the {@link ExecuteResult}.
928
+ *
929
+ * @param command - The executable, arguments, and optional environment and input
930
+ * @param options - Working directory, timeout, capture limit, and failure delivery
931
+ * @returns The run outcome
932
+ * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
933
+ *
934
+ * @example
935
+ * ```ts
936
+ * const result = executeSync({ file: 'git', arguments: ['--version'] }, { strict: false })
937
+ * ```
938
+ */
939
+ function executeSync(command, options) {
940
+ const snapshot = snapshotCommand(command);
941
+ const optionEnvironment = options?.environment;
942
+ const optionWorkspace = options?.workspace;
943
+ const optionInput = options?.input;
944
+ const optionTimeout = options?.timeout;
945
+ const optionStrict = options?.strict;
946
+ const optionLimit = options?.limit;
947
+ validateCommand(snapshot);
948
+ validateEnvironment(optionEnvironment);
949
+ validateWorkspace(optionWorkspace);
950
+ validateTimer(optionTimeout, "option 'timeout'");
951
+ validateBytes(optionLimit, "option 'limit'", 0);
952
+ const limit = optionLimit ?? _src_core.PROCESS_OUTPUT;
953
+ const timeout = optionTimeout ?? 0;
954
+ const strict = optionStrict ?? true;
955
+ const text = optionInput ?? snapshot.input;
956
+ const line = formatCommand(snapshot);
957
+ const workspace = optionWorkspace ?? process.cwd();
958
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
959
+ const plan = buildSpawn(snapshot, {
960
+ workspace,
961
+ environment
962
+ });
963
+ const outcome = (0, node_child_process.spawnSync)(plan.file, [...plan.arguments], {
964
+ cwd: workspace,
965
+ env: environment,
966
+ encoding: "buffer",
967
+ maxBuffer: limit,
968
+ killSignal: "SIGKILL",
969
+ windowsHide: true,
970
+ windowsVerbatimArguments: plan.verbatim,
971
+ ...text !== void 0 ? { input: node_buffer.Buffer.from(text) } : {},
972
+ ...timeout > 0 ? { timeout } : {}
973
+ });
974
+ const error = outcome.error;
975
+ const fault = error !== void 0 && "code" in error && (0, _orkestrel_contract.isString)(error.code) ? error.code : "";
976
+ const result = buildExecuteResult({
977
+ command: line,
978
+ stdout: node_buffer.Buffer.isBuffer(outcome.stdout) ? outcome.stdout : node_buffer.Buffer.alloc(0),
979
+ stderr: node_buffer.Buffer.isBuffer(outcome.stderr) ? outcome.stderr : node_buffer.Buffer.alloc(0),
980
+ code: outcome.status,
981
+ signal: outcome.signal,
982
+ expired: fault === "ETIMEDOUT",
983
+ aborted: false,
984
+ truncated: fault === "ENOBUFS",
985
+ limit,
986
+ ...error === void 0 ? {} : { cause: error }
987
+ });
988
+ if (result.failed && strict) throw (0, _src_core.createExecuteError)(result, error);
989
+ return result;
990
+ }
991
+ /**
992
+ * Spawns one command as a detached process and returns without waiting for it.
993
+ *
994
+ * @remarks
995
+ * The child owns no stdio and is unreferenced, so it outlives this process and nothing here observes
996
+ * its outcome. The error listener is attached before the child is unreferenced, so a post-spawn host
997
+ * fault is swallowed rather than crashing the caller; an invalid command is refused before anything
998
+ * is spawned.
999
+ *
1000
+ * @param command - The executable, arguments, and optional environment
1001
+ * @param options - The working directory the detached child starts in
1002
+ * @returns Nothing
1003
+ * @throws A {@link ProcessError} coded `invalid` when the working directory or a command string is malformed
1004
+ *
1005
+ * @example
1006
+ * ```ts
1007
+ * detach({ file: 'node', arguments: ['daemon.js'] }, { workspace: process.cwd() })
1008
+ * ```
1009
+ */
1010
+ function detach(command, options) {
1011
+ const snapshot = snapshotCommand(command);
1012
+ const optionWorkspace = options?.workspace;
1013
+ validateCommand(snapshot);
1014
+ validateWorkspace(optionWorkspace);
1015
+ const workspace = optionWorkspace ?? process.cwd();
1016
+ const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment);
1017
+ const plan = buildSpawn(snapshot, {
1018
+ workspace,
1019
+ environment
1020
+ });
1021
+ const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
1022
+ cwd: workspace,
1023
+ detached: true,
1024
+ env: environment,
1025
+ stdio: "ignore",
1026
+ windowsHide: true,
1027
+ windowsVerbatimArguments: plan.verbatim
1028
+ });
1029
+ child.once("error", () => void 0);
1030
+ child.unref();
1031
+ }
1032
+ //#endregion
1033
+ //#region src/server/Supervisor.ts
1034
+ /**
1035
+ * Supervises one child process and reports each lifecycle moment to the face composing it.
1036
+ *
1037
+ * @remarks
1038
+ * This is the engine every supervised-child face shares. It owns the eager spawn, the byte-bounded
1039
+ * stderr tail and its decoded chunks, the standard-input channel and its bounded confirmation, the
1040
+ * termination sequence, and the progression from the child's native exit through the bounded `drain`
1041
+ * window to one terminal moment. It owns no output framing and no observation surface: a face
1042
+ * attaches its own consumer to `stdout`, frames bytes however its contract promises, and owns the
1043
+ * emitter and event map its consumers subscribe to.
1044
+ *
1045
+ * The child's ending and the supervision's ending are distinct. `pid`, `code`, and `signal` read the
1046
+ * host child directly, so they expose native exit as soon as the host records it, and `ending`
1047
+ * settles at that same moment. `settled`, `exit`, and `evidence` reach one terminal moment after the
1048
+ * read channels close or the bounded `drain` window cuts them off. That window is armed by the
1049
+ * child's native exit and by a termination this package initiated, so every way a child can end
1050
+ * reaches the moment. The terminal routine hands the face its `close` and `terminal` moments before
1051
+ * it destroys stdout and stderr, so a face's own pull surfaces are final while its read ends still
1052
+ * exist.
1053
+ *
1054
+ * The face is a set of callbacks rather than an emitter, because each face publishes a different
1055
+ * event map over the same moments. `chunk` and `fault` carry the live stderr text and the child or
1056
+ * channel faults, `relieve` reports that a termination began so the face can release whatever
1057
+ * backpressure it holds, `close` ends the face's read pipeline at the terminal moment, `terminal`
1058
+ * carries the frozen {@link ProcessExit}, and `teardown` releases the face's own surface after the
1059
+ * termination completes. `relieve` is optional because a face that never pauses the child's output
1060
+ * holds no backpressure to release, and a face with nothing to do there would otherwise declare an
1061
+ * empty method to say so.
1062
+ */
1063
+ var Supervisor = class {
1064
+ #chunk;
1065
+ #fault;
1066
+ #relieve;
1067
+ #close;
1068
+ #terminal;
1069
+ #teardown;
1070
+ #child;
1071
+ #grace;
1072
+ #drain;
1073
+ #evidence;
1074
+ #delivery;
1075
+ #decoder = new node_string_decoder.StringDecoder("utf8");
1076
+ #exit = Promise.withResolvers();
1077
+ #ending = Promise.withResolvers();
1078
+ #signal;
1079
+ #abort;
1080
+ #writes = /* @__PURE__ */ new Map();
1081
+ #tail = node_buffer.Buffer.alloc(0);
1082
+ #stopping = false;
1083
+ #settled = false;
1084
+ #input = 0;
1085
+ #failure;
1086
+ #waiting;
1087
+ #closure;
1088
+ #termination;
1089
+ #destruction;
1090
+ /**
1091
+ * Spawn one child process and begin standard-error capture.
1092
+ *
1093
+ * @remarks
1094
+ * The face's callbacks are captured before anything is read or spawned, so the first moment the
1095
+ * child can produce already has somewhere to go. `options` supplies the command, workspace,
1096
+ * termination, capture, and standard-input settings; a face's own settings, such as an output
1097
+ * retention bound, are read and validated by that face before it constructs this engine, so an
1098
+ * invalid one refuses construction while nothing has been spawned.
1099
+ *
1100
+ * @param options - Command, workspace, termination, capture, and stdin settings
1101
+ * @param face - The composing face's callbacks for each lifecycle moment
1102
+ * @throws A {@link ProcessError} coded `invalid` when an option or command string is malformed
1103
+ */
1104
+ constructor(options, face) {
1105
+ this.#chunk = face.chunk;
1106
+ this.#fault = face.fault;
1107
+ this.#relieve = face.relieve;
1108
+ this.#close = face.close;
1109
+ this.#terminal = face.terminal;
1110
+ this.#teardown = face.teardown;
1111
+ const source = options.command;
1112
+ const workspace = options.workspace;
1113
+ const grace = options.grace;
1114
+ const drain = options.drain;
1115
+ const evidence = options.evidence;
1116
+ const delivery = options.delivery;
1117
+ const writable = options.writable;
1118
+ const signal = options.signal;
1119
+ const command = snapshotCommand(source);
1120
+ const input = command.input;
1121
+ validateCommand(command);
1122
+ validateWorkspace(workspace);
1123
+ validateTimer(grace, "option 'grace'");
1124
+ validateTimer(drain, "option 'drain'");
1125
+ validateTimer(delivery, "option 'delivery'");
1126
+ validateBytes(evidence, "option 'evidence'", 0);
1127
+ this.#grace = grace ?? _src_core.PROCESS_GRACE;
1128
+ this.#drain = drain ?? _src_core.PROCESS_DRAIN;
1129
+ this.#evidence = evidence ?? _src_core.PROCESS_EVIDENCE;
1130
+ this.#delivery = delivery ?? 0;
1131
+ this.#signal = signal;
1132
+ const childEnvironment = mergeEnvironment(command.isolated === true, command.environment);
1133
+ const plan = buildSpawn(command, {
1134
+ workspace,
1135
+ environment: childEnvironment
1136
+ });
1137
+ this.#child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
1138
+ cwd: workspace,
1139
+ detached: process.platform !== "win32",
1140
+ env: childEnvironment,
1141
+ stdio: [
1142
+ "pipe",
1143
+ "pipe",
1144
+ "pipe"
1145
+ ],
1146
+ windowsHide: true,
1147
+ windowsVerbatimArguments: plan.verbatim
1148
+ });
1149
+ this.#child.once("error", this.#fault);
821
1150
  this.#child.once("exit", this.#expire.bind(this));
822
- this.#child.once("close", this.#close.bind(this));
823
- this.#child.stdin.on("error", (cause) => this.#failInputStream(cause));
1151
+ this.#child.once("close", this.#complete.bind(this));
1152
+ this.#child.stdin.on("error", this.#failInputStream.bind(this));
824
1153
  this.#child.stderr.on("data", this.#retain.bind(this));
825
1154
  if (input !== void 0) {
826
1155
  this.#input += 1;
@@ -836,6 +1165,10 @@ var Process = class {
836
1165
  if (signal.aborted) this.stop();
837
1166
  }
838
1167
  }
1168
+ /** The child's standard-output stream, for the composing face to attach its own consumer to. */
1169
+ get stdout() {
1170
+ return this.#child.stdout;
1171
+ }
839
1172
  /** The host process id, fixed when construction returns, or `undefined` when the spawn produced none. */
840
1173
  get pid() {
841
1174
  return this.#child.pid;
@@ -848,22 +1181,10 @@ var Process = class {
848
1181
  get signal() {
849
1182
  return this.#child.signalCode;
850
1183
  }
851
- /** The typed lifecycle observation surface. */
852
- get emitter() {
853
- return this.#emitter;
854
- }
855
- /** The captured stdout lines, in arrival order, ending after queued lines at the terminal moment. */
856
- get lines() {
857
- return this.#lines;
858
- }
859
1184
  /** The decoded byte-bounded stderr tail, frozen at the terminal moment. */
860
1185
  get evidence() {
861
1186
  return this.#tail.toString("utf8");
862
1187
  }
863
- /** True when the `lines` stream omitted output after a retention bound was reached. */
864
- get truncated() {
865
- return this.#truncated;
866
- }
867
1188
  /** True after the terminal moment arrived and `exit` settled. */
868
1189
  get settled() {
869
1190
  return this.#settled;
@@ -872,34 +1193,43 @@ var Process = class {
872
1193
  get stopping() {
873
1194
  return this.#stopping;
874
1195
  }
1196
+ /**
1197
+ * The child's own ending, awaited without the terminal moment's drain window.
1198
+ *
1199
+ * @remarks
1200
+ * Never rejects, and resolves no value: `code` and `signal` carry the terminal facts as soon as
1201
+ * the host records them. It settles at the native exit, and at the terminal moment for a child
1202
+ * whose spawn produced no native exit at all, so it can never outlive the supervision.
1203
+ */
1204
+ get ending() {
1205
+ return this.#ending.promise;
1206
+ }
875
1207
  /** The terminal child state, observed once after stream close or the drain cutoff. */
876
1208
  get exit() {
877
1209
  return this.#exit.promise;
878
1210
  }
879
1211
  /**
880
- * Write one line to the open standard-input channel.
1212
+ * Writes raw bytes to the open standard-input channel.
881
1213
  *
882
1214
  * @remarks
883
- * Never rejects. `true` means the host accepted the bytes without reporting a fault; it does not
884
- * prove that the child read them. An ordinary write settles when the kernel accepts it. Only a
885
- * full pipe can hold the write unconfirmed. The `delivery` option can bound that wait, and every
886
- * terminal teardown path settles pending writes. On Windows 11 with Node v24.18.1, measured on
887
- * 2026-08-21, a child that closes its own file descriptor 0 can leave the parent pipe writable:
888
- * the write can settle `true` without a callback error or a stream error while the child remains
889
- * alive. After `stop` or `destroy` begins, a later call settles `false`. Version 0.0.4 could settle
890
- * that call `true` before teardown destroyed the pipe; returning `false` avoids claiming delivery
891
- * for bytes the package is about to discard.
1215
+ * Never rejects, and adds no framing: a face composes whatever terminator its contract promises
1216
+ * before it calls this. `true` means the host accepted the bytes without reporting a fault; it
1217
+ * does not prove that the child read them. An ordinary write settles when the kernel accepts it.
1218
+ * Only a full pipe can hold the write unconfirmed. The `delivery` option can bound that wait, and
1219
+ * every terminal teardown path settles pending writes. After `stop` or `destroy` begins, a later
1220
+ * call settles `false`, because teardown cannot confirm delivery for bytes it is about to
1221
+ * discard.
892
1222
  *
893
- * @param text - The line text without its trailing newline
1223
+ * @param bytes - The payload to write, already framed by the caller
894
1224
  * @returns True when the host accepted the bytes without reporting a fault; false when the channel was closed, destroyed, ended, failed, or remained unconfirmed through `delivery`
895
1225
  */
896
- send(text) {
1226
+ deliver(bytes) {
897
1227
  const stdin = this.#child.stdin;
898
1228
  if (this.#settled || this.#stopping || this.#failure !== void 0 || !stdin.writable) return Promise.resolve(false);
899
1229
  const settled = Promise.withResolvers();
900
1230
  this.#writes.set(settled, void 0);
901
1231
  try {
902
- stdin.write(`${text}\n`, this.#confirmWrite.bind(this, settled));
1232
+ stdin.write(bytes, this.#confirmWrite.bind(this, settled));
903
1233
  } catch {
904
1234
  this.#settleWrite(settled, false);
905
1235
  return settled.promise;
@@ -912,6 +1242,28 @@ var Process = class {
912
1242
  return settled.promise;
913
1243
  }
914
1244
  /**
1245
+ * Closes the standard-input channel while leaving the child running.
1246
+ *
1247
+ * @remarks
1248
+ * Never rejects, and terminates nothing: no signal is sent, `stopping` stays false, no drain
1249
+ * window is armed, and the terminal moment does not arrive. The child observes end of input and
1250
+ * decides for itself what to do about it, which is what separates this from `stop`.
1251
+ *
1252
+ * Every call shares one barrier, and it resolves after the host flushes the writes it had already
1253
+ * accepted, or reports that it cannot. A `deliver` after this call resolves `false`, because the
1254
+ * host stops reporting the channel writable the moment it is ended. That same flag keeps the
1255
+ * ended channel quiet for its remaining life, so a later host fault on it settles pending writes
1256
+ * and reaches no face. After `stop` or `destroy` has begun, or after the terminal moment, the
1257
+ * call resolves and changes nothing, because the channel is already being discarded.
1258
+ *
1259
+ * @returns The stable barrier shared by every call
1260
+ */
1261
+ end() {
1262
+ if (this.#closure !== void 0) return this.#closure;
1263
+ this.#closure = this.#endInput();
1264
+ return this.#closure;
1265
+ }
1266
+ /**
915
1267
  * Terminates the child process tree and reaches the terminal observation moment.
916
1268
  *
917
1269
  * @remarks
@@ -928,7 +1280,7 @@ var Process = class {
928
1280
  return this.#termination;
929
1281
  }
930
1282
  /**
931
- * Stops the child and destroys the observation emitter after the terminal state freezes.
1283
+ * Stops the child and releases the composing face after the terminal state freezes.
932
1284
  *
933
1285
  * @remarks
934
1286
  * Always resolves, including when termination was never confirmed. Every call shares one barrier.
@@ -936,95 +1288,22 @@ var Process = class {
936
1288
  * @returns The stable barrier shared by every call
937
1289
  */
938
1290
  destroy() {
939
- if (this.#ending !== void 0) return this.#ending;
940
- this.#ending = this.#end();
941
- return this.#ending;
1291
+ if (this.#destruction !== void 0) return this.#destruction;
1292
+ this.#destruction = this.#conclude();
1293
+ return this.#destruction;
942
1294
  }
943
- #iterate() {
944
- this.#requested = true;
945
- return Object.freeze({ next: this.#next.bind(this) });
1295
+ #retain(chunk) {
1296
+ if (!node_buffer.Buffer.isBuffer(chunk) || this.#settled) return;
1297
+ this.#tail = trimTail(node_buffer.Buffer.concat([this.#tail, chunk]), this.#evidence);
1298
+ const text = this.#decoder.write(chunk);
1299
+ if (text.length > 0) this.#chunk(text);
946
1300
  }
947
- #next() {
948
- const line = this.#take();
949
- if (line !== void 0) return Promise.resolve(Object.freeze({
950
- done: false,
951
- value: line
952
- }));
953
- if (this.#ended) return Promise.resolve(Object.freeze({
954
- done: true,
955
- value: void 0
956
- }));
957
- const waiter = Promise.withResolvers();
958
- this.#waiters.push(waiter);
959
- return waiter.promise;
960
- }
961
- #take() {
962
- if (this.#head >= this.#queue.length) return void 0;
963
- const line = this.#queue[this.#head];
964
- this.#queue[this.#head] = "";
965
- this.#head += 1;
966
- if (this.#head >= this.#queue.length) {
967
- this.#queue.length = 0;
968
- this.#head = 0;
969
- }
970
- if (line === void 0) return void 0;
971
- this.#pending -= node_buffer.Buffer.byteLength(line) + 1;
972
- this.#relieve();
973
- return line;
974
- }
975
- #push(line) {
976
- if (this.#ended) return;
977
- const waiter = this.#waiters.shift();
978
- if (waiter !== void 0) {
979
- waiter.resolve(Object.freeze({
980
- done: false,
981
- value: line
982
- }));
983
- return;
984
- }
985
- const bytes = node_buffer.Buffer.byteLength(line) + 1;
986
- const limit = this.#stopping ? this.#backlog * 2 : this.#backlog;
987
- if ((!this.#requested || this.#stopping) && this.#pending + bytes > limit) {
988
- this.#truncated = true;
989
- return;
990
- }
991
- this.#queue.push(line);
992
- this.#pending += bytes;
993
- this.#restrain();
994
- }
995
- #restrain() {
996
- if (this.#paused || this.#stopping || !this.#requested) return;
997
- if (this.#pending < this.#backlog) return;
998
- this.#paused = true;
999
- this.#reader.pause();
1000
- }
1001
- #relieve() {
1002
- if (!this.#paused || this.#ended) return;
1003
- if (!this.#stopping && this.#pending > this.#backlog / 2) return;
1004
- this.#paused = false;
1005
- this.#reader.resume();
1006
- }
1007
- #finish() {
1008
- if (this.#ended) return;
1009
- this.#ended = true;
1010
- this.#paused = false;
1011
- for (const waiter of this.#waiters) waiter.resolve(Object.freeze({
1012
- done: true,
1013
- value: void 0
1014
- }));
1015
- this.#waiters.length = 0;
1016
- }
1017
- #retain(chunk) {
1018
- if (!node_buffer.Buffer.isBuffer(chunk) || this.#settled) return;
1019
- this.#tail = trimTail(node_buffer.Buffer.concat([this.#tail, chunk]), this.#evidence);
1020
- const text = this.#decoder.write(chunk);
1021
- if (text.length > 0) this.#emitter.emit("stderr", text);
1022
- }
1023
- #close() {
1301
+ #complete() {
1024
1302
  if (this.#settled) return;
1025
1303
  this.#settle(true);
1026
1304
  }
1027
1305
  #expire() {
1306
+ this.#ending.resolve();
1028
1307
  if (this.#settled) return;
1029
1308
  this.#wait();
1030
1309
  }
@@ -1036,17 +1315,18 @@ var Process = class {
1036
1315
  }
1037
1316
  #settle(drained) {
1038
1317
  const suffix = this.#decoder.end();
1039
- if (suffix.length > 0) this.#emitter.emit("stderr", suffix);
1318
+ if (suffix.length > 0) this.#chunk(suffix);
1040
1319
  this.#settled = true;
1041
1320
  this.#removeAbortListener();
1042
- this.#reader.close();
1321
+ this.#close();
1043
1322
  const exit = Object.freeze({
1044
1323
  code: this.code,
1045
1324
  signal: this.signal,
1046
1325
  drained
1047
1326
  });
1048
1327
  this.#exit.resolve(exit);
1049
- this.#emitter.emit("exit", exit);
1328
+ this.#ending.resolve();
1329
+ this.#terminal(exit);
1050
1330
  this.#child.stdout.destroy();
1051
1331
  this.#child.stderr.destroy();
1052
1332
  }
@@ -1067,6 +1347,13 @@ var Process = class {
1067
1347
  #completeInput() {
1068
1348
  this.#input -= 1;
1069
1349
  }
1350
+ #endInput() {
1351
+ const stdin = this.#child.stdin;
1352
+ if (this.#stopping || this.#settled || stdin.writableEnded || stdin.destroyed) return Promise.resolve();
1353
+ const ended = Promise.withResolvers();
1354
+ stdin.end(() => ended.resolve());
1355
+ return ended.promise;
1356
+ }
1070
1357
  #settleWrite(settled, accepted) {
1071
1358
  if (!this.#writes.has(settled)) return;
1072
1359
  const timer = this.#writes.get(settled);
@@ -1078,34 +1365,28 @@ var Process = class {
1078
1365
  for (const settled of this.#writes.keys()) this.#settleWrite(settled, false);
1079
1366
  }
1080
1367
  #failInputStream(cause) {
1081
- if (this.#child.stdin.writableEnded || this.#input > 0) {
1368
+ if (this.#input > 0) {
1082
1369
  this.#settleWrites();
1083
1370
  return;
1084
1371
  }
1085
1372
  this.#failInputCallback(cause);
1086
1373
  }
1087
1374
  #failInputCallback(cause) {
1375
+ if (this.#child.stdin.writableEnded) {
1376
+ this.#settleWrites();
1377
+ return;
1378
+ }
1088
1379
  if (this.#failure !== void 0 || this.#stopping) return;
1089
1380
  this.#failure = cause;
1090
1381
  this.#settleWrites();
1091
- this.#emitter.emit("error", new _src_core.ProcessError("The standard-input channel failed", {
1382
+ this.#fault(new _src_core.ProcessError("The standard-input channel failed", {
1092
1383
  code: "protocol",
1093
1384
  cause
1094
1385
  }));
1095
1386
  }
1096
- #capBacklog() {
1097
- const limit = this.#backlog * 2;
1098
- while (this.#pending > limit && this.#queue.length > this.#head) {
1099
- const line = this.#queue.pop();
1100
- if (line === void 0) break;
1101
- this.#pending -= node_buffer.Buffer.byteLength(line) + 1;
1102
- this.#truncated = true;
1103
- }
1104
- }
1105
1387
  async #kill() {
1106
1388
  this.#stopping = true;
1107
- this.#capBacklog();
1108
- this.#relieve();
1389
+ this.#relieve?.();
1109
1390
  const confirmed = await stopChild(this.#child, this.#grace, _src_core.PROCESS_CONFIRMATION);
1110
1391
  this.#settleWrites();
1111
1392
  this.#child.stdin.destroy();
@@ -1113,97 +1394,386 @@ var Process = class {
1113
1394
  if (!this.#settled) this.#settle(false);
1114
1395
  return confirmed;
1115
1396
  }
1116
- async #end() {
1397
+ async #conclude() {
1117
1398
  await this.stop();
1118
- this.#emitter.destroy();
1399
+ this.#teardown();
1119
1400
  }
1120
1401
  };
1121
1402
  //#endregion
1122
- //#region src/server/ProcessManager.ts
1403
+ //#region src/server/Process.ts
1123
1404
  /**
1124
- * A keyed registry of live supervised child processes.
1405
+ * Supervises one child while keeping every observation channel aligned at termination.
1125
1406
  *
1126
1407
  * @remarks
1127
- * A child launched under an id joins the registry and emits `launch`; when it settles it removes
1128
- * itself and emits `exit`, so `count` and `processes` reflect only live children. The id is reserved
1129
- * before the child is constructed and released when construction throws, so a refused launch never
1130
- * strands its key. Eviction follows the child's own `exit` promise, which no listener can forge, so
1131
- * it lands one microtask after the child's public `exit` event: a listener on that event still sees
1132
- * the child registered. `launch` throws a {@link createDuplicateError} when the id is already live
1133
- * and a {@link createProtocolError} after `destroy` has begun, including when the caller's own option
1134
- * getter begins that teardown mid-construction — a spawn fault surfaces through the returned child's
1135
- * `exit`, never from `launch`. `destroy` awaits every child's own teardown, including the teardown
1136
- * of a child a lost race refused, which destroys each child's observation emitter and leaves every
1137
- * subscription on it silently inert, then destroys the registry emitter last.
1408
+ * The child's ending and the supervision's ending are distinct. `pid`, `code`, and `signal` read
1409
+ * the host child directly, so they expose native exit as soon as the host records it. `settled`,
1410
+ * `exit`, `evidence`, and `lines` reach one terminal moment after the read channels close or the
1411
+ * bounded `drain` window cuts them off. That window is armed by the child's native exit and by a
1412
+ * termination this package initiated, so every way a child can end reaches the moment. The terminal
1413
+ * routine freezes `evidence` and ends `lines` before it destroys stdout, stderr, or the emitter, so
1414
+ * the push channel never disappears while a pull channel can still change or wait indefinitely.
1415
+ *
1416
+ * Standard output is framed through `readline` into a single-consumer stream. Lines already framed
1417
+ * and queued remain available through the terminal moment, while bytes arriving after a requested
1418
+ * termination may be cut off. Standard error is forwarded through the live `stderr` event and
1419
+ * retained as a byte-bounded raw tail until that same moment. `stop` ends the child tree and the
1420
+ * observation surfaces. `destroy` performs that stop and then destroys the emitter after the
1421
+ * frozen terminal state exists.
1138
1422
  *
1139
1423
  * @example
1140
1424
  * ```ts
1141
- * import { ProcessManager } from '@orkestrel/process/server'
1425
+ * import { Process } from '@orkestrel/process/server'
1142
1426
  *
1143
- * const manager = new ProcessManager()
1144
- * const child = manager.launch('build', {
1145
- * command: { file: 'node', arguments: ['build.js'] },
1427
+ * const child = new Process({
1428
+ * command: { file: 'node', arguments: ['--version'] },
1146
1429
  * workspace: process.cwd(),
1147
1430
  * })
1148
- * await child.exit
1149
- * await manager.destroy()
1431
+ * const exit = await child.exit
1432
+ * await child.destroy()
1150
1433
  * ```
1151
1434
  */
1152
- var ProcessManager = class {
1435
+ var Process = class {
1153
1436
  #emitter;
1154
- #children = /* @__PURE__ */ new Map();
1155
- #ids = /* @__PURE__ */ new Set();
1156
- #orphans = /* @__PURE__ */ new Set();
1157
- #destroying = false;
1158
- #ending;
1437
+ #engine;
1438
+ #reader;
1439
+ #backlog;
1440
+ #lines;
1441
+ #queue = [];
1442
+ #waiters = [];
1443
+ #head = 0;
1444
+ #pending = 0;
1445
+ #requested = false;
1446
+ #paused = false;
1447
+ #truncated = false;
1448
+ #ended = false;
1159
1449
  /**
1160
- * Construct a process registry.
1450
+ * Spawn one child process and begin stream capture.
1161
1451
  *
1162
- * @param options - Initial fleet-level observation hooks and listener-error handling
1452
+ * @param options - Command, workspace, termination, capture, stdin, and observation settings
1453
+ * @throws A {@link ProcessError} coded `invalid` when an option or command string is malformed
1163
1454
  */
1164
1455
  constructor(options) {
1165
- const on = options?.on;
1166
- const error = options?.error;
1456
+ const source = options.command;
1457
+ const workspace = options.workspace;
1458
+ const grace = options.grace;
1459
+ const drain = options.drain;
1460
+ const evidence = options.evidence;
1461
+ const backlog = options.backlog;
1462
+ const delivery = options.delivery;
1463
+ const writable = options.writable;
1464
+ const signal = options.signal;
1465
+ const on = options.on;
1466
+ const error = options.error;
1467
+ const command = snapshotCommand(source);
1468
+ validateCommand(command);
1469
+ validateWorkspace(workspace);
1470
+ validateTimer(grace, "option 'grace'");
1471
+ validateTimer(drain, "option 'drain'");
1472
+ validateTimer(delivery, "option 'delivery'");
1473
+ validateBytes(evidence, "option 'evidence'", 0);
1474
+ validateBytes(backlog, "option 'backlog'", 1);
1167
1475
  this.#emitter = new _orkestrel_emitter.Emitter({
1168
1476
  ...on === void 0 ? {} : { on },
1169
1477
  ...error === void 0 ? {} : { error }
1170
1478
  });
1479
+ this.#backlog = backlog ?? _src_core.PROCESS_BACKLOG;
1480
+ this.#lines = Object.freeze({ [Symbol.asyncIterator]: this.#iterate.bind(this) });
1481
+ this.#engine = new Supervisor({
1482
+ command,
1483
+ workspace,
1484
+ ...grace === void 0 ? {} : { grace },
1485
+ ...drain === void 0 ? {} : { drain },
1486
+ ...evidence === void 0 ? {} : { evidence },
1487
+ ...delivery === void 0 ? {} : { delivery },
1488
+ ...writable === void 0 ? {} : { writable },
1489
+ ...signal === void 0 ? {} : { signal }
1490
+ }, {
1491
+ chunk: this.#reportStderr.bind(this),
1492
+ fault: this.#reportFault.bind(this),
1493
+ relieve: this.#releaseBackpressure.bind(this),
1494
+ close: this.#closeReader.bind(this),
1495
+ terminal: this.#reportExit.bind(this),
1496
+ teardown: this.#emitter.destroy.bind(this.#emitter)
1497
+ });
1498
+ this.#reader = (0, node_readline.createInterface)({
1499
+ input: this.#engine.stdout,
1500
+ crlfDelay: Infinity
1501
+ });
1502
+ this.#reader.on("line", this.#push.bind(this));
1503
+ this.#reader.once("close", this.#finish.bind(this));
1171
1504
  }
1172
- /** The typed fleet-level observation surface. */
1505
+ /** The host process id, fixed when construction returns, or `undefined` when the spawn produced none. */
1506
+ get pid() {
1507
+ return this.#engine.pid;
1508
+ }
1509
+ /** The exit code the host recorded, or `null` while the child has not exited and when a signal ended it. */
1510
+ get code() {
1511
+ return this.#engine.code;
1512
+ }
1513
+ /** The terminating signal name the host recorded, or `null` while the child has not exited and when it exited on its own. */
1514
+ get signal() {
1515
+ return this.#engine.signal;
1516
+ }
1517
+ /** The typed lifecycle observation surface. */
1173
1518
  get emitter() {
1174
1519
  return this.#emitter;
1175
1520
  }
1176
- /** The number of live children. */
1177
- get count() {
1178
- return this.#children.size;
1521
+ /** The captured stdout lines, in arrival order, ending after queued lines at the terminal moment. */
1522
+ get lines() {
1523
+ return this.#lines;
1524
+ }
1525
+ /** The decoded byte-bounded stderr tail, frozen at the terminal moment. */
1526
+ get evidence() {
1527
+ return this.#engine.evidence;
1528
+ }
1529
+ /** True when the `lines` stream omitted output after a retention bound was reached. */
1530
+ get truncated() {
1531
+ return this.#truncated;
1532
+ }
1533
+ /** True after the terminal moment arrived and `exit` settled. */
1534
+ get settled() {
1535
+ return this.#engine.settled;
1536
+ }
1537
+ /** True after termination began, including after the terminal moment. */
1538
+ get stopping() {
1539
+ return this.#engine.stopping;
1540
+ }
1541
+ /** The terminal child state, observed once after stream close or the drain cutoff. */
1542
+ get exit() {
1543
+ return this.#engine.exit;
1179
1544
  }
1180
1545
  /**
1181
- * The live child under `id`, or `undefined` when none is.
1546
+ * Write one line to the open standard-input channel.
1182
1547
  *
1183
- * @param id - The registry key
1184
- * @returns The child, or `undefined`
1548
+ * @remarks
1549
+ * Never rejects. `true` means the host accepted the bytes without reporting a fault; it does not
1550
+ * prove that the child read them. An ordinary write settles when the kernel accepts it. Only a
1551
+ * full pipe can hold the write unconfirmed. The `delivery` option can bound that wait, and every
1552
+ * terminal teardown path settles pending writes. On Windows 11 with Node v24.18.1, measured on
1553
+ * 2026-08-21, a child that closes its own file descriptor 0 can leave the parent pipe writable:
1554
+ * the write can settle `true` without a callback error or a stream error while the child remains
1555
+ * alive. After `stop` or `destroy` begins, a later call settles `false`. Version 0.0.4 could settle
1556
+ * that call `true` before teardown destroyed the pipe; returning `false` avoids claiming delivery
1557
+ * for bytes the package is about to discard.
1558
+ *
1559
+ * @param text - The line text without its trailing newline
1560
+ * @returns True when the host accepted the bytes without reporting a fault; false when the channel was closed, destroyed, ended, failed, or remained unconfirmed through `delivery`
1185
1561
  */
1186
- process(id) {
1187
- return this.#children.get(id);
1562
+ send(text) {
1563
+ return this.#engine.deliver(node_buffer.Buffer.from(`${text}\n`, "utf8"));
1188
1564
  }
1189
1565
  /**
1190
- * A snapshot of every live child.
1566
+ * Terminates the child process tree and reaches the terminal observation moment.
1191
1567
  *
1192
- * @returns The live children in launch order
1568
+ * @remarks
1569
+ * Never rejects, and every call shares one termination. After the termination sequence returns,
1570
+ * confirmed or not, it waits at most `drain` for the read channels to close, then cuts them off
1571
+ * and settles `exit` when they remain open. A cutoff reached before the native exit reports the
1572
+ * `code` and `signal` the host had recorded by then, which is `null` for a child still running.
1573
+ *
1574
+ * @returns True when the child's native exit was observed; false when the confirmation deadline elapsed without it
1193
1575
  */
1194
- processes() {
1195
- return [...this.#children.values()];
1576
+ stop() {
1577
+ return this.#engine.stop();
1196
1578
  }
1197
1579
  /**
1198
- * Spawn and register one child under `id`.
1580
+ * Stops the child and destroys the observation emitter after the terminal state freezes.
1199
1581
  *
1200
1582
  * @remarks
1201
- * {@link Process} reads every option before it spawns, so a caller's own option getter runs while
1202
- * nothing has started and a throw from one strands no process. A getter that begins `destroy`
1203
- * without throwing is the one remaining race: the child is already spawned, so the launch is
1204
- * refused with a {@link createProtocolError} and that child is destroyed rather than adopted, its
1205
- * teardown bounded by `grace` plus the confirmation window. The `protocol` refusal throws
1206
- * synchronously, and the `destroy` barrier covers that teardown, so the refused child reaches its
1583
+ * Always resolves, including when termination was never confirmed. Every call shares one barrier.
1584
+ *
1585
+ * @returns The stable barrier shared by every call
1586
+ */
1587
+ destroy() {
1588
+ return this.#engine.destroy();
1589
+ }
1590
+ #iterate() {
1591
+ this.#requested = true;
1592
+ return Object.freeze({ next: this.#next.bind(this) });
1593
+ }
1594
+ #next() {
1595
+ const line = this.#take();
1596
+ if (line !== void 0) return Promise.resolve(Object.freeze({
1597
+ done: false,
1598
+ value: line
1599
+ }));
1600
+ if (this.#ended) return Promise.resolve(Object.freeze({
1601
+ done: true,
1602
+ value: void 0
1603
+ }));
1604
+ const waiter = Promise.withResolvers();
1605
+ this.#waiters.push(waiter);
1606
+ return waiter.promise;
1607
+ }
1608
+ #take() {
1609
+ if (this.#head >= this.#queue.length) return void 0;
1610
+ const line = this.#queue[this.#head];
1611
+ this.#queue[this.#head] = "";
1612
+ this.#head += 1;
1613
+ if (this.#head >= this.#queue.length) {
1614
+ this.#queue.length = 0;
1615
+ this.#head = 0;
1616
+ }
1617
+ if (line === void 0) return void 0;
1618
+ this.#pending -= node_buffer.Buffer.byteLength(line) + 1;
1619
+ this.#relieve();
1620
+ return line;
1621
+ }
1622
+ #push(line) {
1623
+ if (this.#ended) return;
1624
+ const waiter = this.#waiters.shift();
1625
+ if (waiter !== void 0) {
1626
+ waiter.resolve(Object.freeze({
1627
+ done: false,
1628
+ value: line
1629
+ }));
1630
+ return;
1631
+ }
1632
+ const bytes = node_buffer.Buffer.byteLength(line) + 1;
1633
+ const stopping = this.#engine.stopping;
1634
+ const limit = stopping ? this.#backlog * 2 : this.#backlog;
1635
+ if ((!this.#requested || stopping) && this.#pending + bytes > limit) {
1636
+ this.#truncated = true;
1637
+ return;
1638
+ }
1639
+ this.#queue.push(line);
1640
+ this.#pending += bytes;
1641
+ this.#restrain();
1642
+ }
1643
+ #restrain() {
1644
+ if (this.#paused || this.#engine.stopping || !this.#requested) return;
1645
+ if (this.#pending < this.#backlog) return;
1646
+ this.#paused = true;
1647
+ this.#reader.pause();
1648
+ }
1649
+ #relieve() {
1650
+ if (!this.#paused || this.#ended) return;
1651
+ if (!this.#engine.stopping && this.#pending > this.#backlog / 2) return;
1652
+ this.#paused = false;
1653
+ this.#reader.resume();
1654
+ }
1655
+ #finish() {
1656
+ if (this.#ended) return;
1657
+ this.#ended = true;
1658
+ this.#paused = false;
1659
+ for (const waiter of this.#waiters) waiter.resolve(Object.freeze({
1660
+ done: true,
1661
+ value: void 0
1662
+ }));
1663
+ this.#waiters.length = 0;
1664
+ }
1665
+ #reportStderr(chunk) {
1666
+ this.#emitter.emit("stderr", chunk);
1667
+ }
1668
+ #reportFault(cause) {
1669
+ this.#emitter.emit("error", cause);
1670
+ }
1671
+ #reportExit(exit) {
1672
+ this.#emitter.emit("exit", exit);
1673
+ }
1674
+ #closeReader() {
1675
+ this.#reader.close();
1676
+ }
1677
+ #releaseBackpressure() {
1678
+ this.#capBacklog();
1679
+ this.#relieve();
1680
+ }
1681
+ #capBacklog() {
1682
+ const limit = this.#backlog * 2;
1683
+ while (this.#pending > limit && this.#queue.length > this.#head) {
1684
+ const line = this.#queue.pop();
1685
+ if (line === void 0) break;
1686
+ this.#pending -= node_buffer.Buffer.byteLength(line) + 1;
1687
+ this.#truncated = true;
1688
+ }
1689
+ }
1690
+ };
1691
+ //#endregion
1692
+ //#region src/server/ProcessManager.ts
1693
+ /**
1694
+ * A keyed registry of live supervised child processes.
1695
+ *
1696
+ * @remarks
1697
+ * A child launched under an id joins the registry and emits `launch`; when it settles it removes
1698
+ * itself and emits `exit`, so `count` and `processes` reflect only live children. The id is reserved
1699
+ * before the child is constructed and released when construction throws, so a refused launch never
1700
+ * strands its key. Eviction follows the child's own `exit` promise, which no listener can forge, so
1701
+ * it lands one microtask after the child's public `exit` event: a listener on that event still sees
1702
+ * the child registered. `launch` throws a {@link createDuplicateError} when the id is already live
1703
+ * and a {@link createProtocolError} after `destroy` has begun, including when the caller's own option
1704
+ * getter begins that teardown mid-construction — a spawn fault surfaces through the returned child's
1705
+ * `exit`, never from `launch`. `destroy` awaits every child's own teardown, including the teardown
1706
+ * of a child a lost race refused, which destroys each child's observation emitter and leaves every
1707
+ * subscription on it silently inert, then destroys the registry emitter last.
1708
+ *
1709
+ * @example
1710
+ * ```ts
1711
+ * import { ProcessManager } from '@orkestrel/process/server'
1712
+ *
1713
+ * const manager = new ProcessManager()
1714
+ * const child = manager.launch('build', {
1715
+ * command: { file: 'node', arguments: ['build.js'] },
1716
+ * workspace: process.cwd(),
1717
+ * })
1718
+ * await child.exit
1719
+ * await manager.destroy()
1720
+ * ```
1721
+ */
1722
+ var ProcessManager = class {
1723
+ #emitter;
1724
+ #children = /* @__PURE__ */ new Map();
1725
+ #ids = /* @__PURE__ */ new Set();
1726
+ #orphans = /* @__PURE__ */ new Set();
1727
+ #destroying = false;
1728
+ #ending;
1729
+ /**
1730
+ * Construct a process registry.
1731
+ *
1732
+ * @param options - Initial fleet-level observation hooks and listener-error handling
1733
+ */
1734
+ constructor(options) {
1735
+ const on = options?.on;
1736
+ const error = options?.error;
1737
+ this.#emitter = new _orkestrel_emitter.Emitter({
1738
+ ...on === void 0 ? {} : { on },
1739
+ ...error === void 0 ? {} : { error }
1740
+ });
1741
+ }
1742
+ /** The typed fleet-level observation surface. */
1743
+ get emitter() {
1744
+ return this.#emitter;
1745
+ }
1746
+ /** The number of live children. */
1747
+ get count() {
1748
+ return this.#children.size;
1749
+ }
1750
+ /**
1751
+ * The live child under `id`, or `undefined` when none is.
1752
+ *
1753
+ * @param id - The registry key
1754
+ * @returns The child, or `undefined`
1755
+ */
1756
+ process(id) {
1757
+ return this.#children.get(id);
1758
+ }
1759
+ /**
1760
+ * A snapshot of every live child.
1761
+ *
1762
+ * @returns The live children in launch order
1763
+ */
1764
+ processes() {
1765
+ return [...this.#children.values()];
1766
+ }
1767
+ /**
1768
+ * Spawn and register one child under `id`.
1769
+ *
1770
+ * @remarks
1771
+ * {@link Process} reads every option before it spawns, so a caller's own option getter runs while
1772
+ * nothing has started and a throw from one strands no process. A getter that begins `destroy`
1773
+ * without throwing is the one remaining race: the child is already spawned, so the launch is
1774
+ * refused with a {@link createProtocolError} and that child is destroyed rather than adopted, its
1775
+ * teardown bounded by `grace` plus the confirmation window. The `protocol` refusal throws
1776
+ * synchronously, and the `destroy` barrier covers that teardown, so the refused child reaches its
1207
1777
  * terminal moment before the barrier resolves.
1208
1778
  *
1209
1779
  * @param id - The registry key, unique among live children
@@ -1286,390 +1856,254 @@ var ProcessManager = class {
1286
1856
  }
1287
1857
  };
1288
1858
  //#endregion
1289
- //#region src/server/factories.ts
1859
+ //#region src/server/Session.ts
1290
1860
  /**
1291
- * Creates one supervised child process.
1861
+ * Supervises one child and publishes its standard output as raw bytes.
1292
1862
  *
1293
- * @param options - Command, workspace, termination, evidence, stdin, and observation settings
1294
- * @returns The launched {@link ProcessInterface}
1863
+ * @remarks
1864
+ * This is the transport face of the supervision engine `Process` also composes. It frames nothing,
1865
+ * retains nothing, and decodes nothing on the output side: every chunk the host delivers becomes one
1866
+ * `stdout` event carrying that chunk's bytes, in arrival order, so a consumer speaking a
1867
+ * length-prefixed or delimiter-framed protocol reads exactly what the child wrote and frames it
1868
+ * itself. Standard error is decoded and forwarded as `stderr`, with the same byte-bounded `evidence`
1869
+ * tail a `Process` retains, because a diagnostic stream is read rather than parsed.
1870
+ *
1871
+ * Each emitted chunk is this session's own copy, and a plain `Uint8Array` rather than the host buffer
1872
+ * the read produced, so a consumer keeps it, mutates it, concatenates it, and reads its whole backing
1873
+ * buffer without reaching memory the host still manages and without depending on a host type.
1874
+ *
1875
+ * The standard-input channel is open from the spawn, and `end` closes it without terminating
1876
+ * anything. That is the member a cooperative protocol reaches for: the child observes end of input
1877
+ * and exits on its own, which `ending` reports. `stop` and `destroy` remain the terminations, and
1878
+ * `exit` remains the terminal moment — the native exit plus at most `drain` while a descendant holds
1879
+ * the inherited read ends.
1295
1880
  *
1296
1881
  * @example
1297
1882
  * ```ts
1298
- * import { createProcess } from '@orkestrel/process/server'
1883
+ * import { Session } from '@orkestrel/process/server'
1299
1884
  *
1300
- * const child = createProcess({
1301
- * command: { file: 'node', arguments: ['--version'] },
1885
+ * const session = new Session({
1886
+ * command: { file: 'node', arguments: ['-e', 'process.stdin.pipe(process.stdout)'] },
1302
1887
  * workspace: process.cwd(),
1303
- * grace: 5000,
1304
1888
  * })
1305
1889
  * ```
1306
1890
  */
1307
- function createProcess(options) {
1308
- return new Process(options);
1309
- }
1310
- /**
1311
- * Creates one keyed registry of supervised child processes.
1312
- *
1313
- * @param options - Initial fleet-level observation hooks and listener-error handling
1314
- * @returns The {@link ProcessManagerInterface} registry
1315
- *
1316
- * @example
1317
- * ```ts
1318
- * import { createProcessManager } from '@orkestrel/process/server'
1319
- *
1320
- * const manager = createProcessManager()
1321
- * ```
1322
- */
1323
- function createProcessManager(options) {
1324
- return new ProcessManager(options);
1325
- }
1326
- //#endregion
1327
- //#region src/server/Retention.ts
1328
- /**
1329
- * Accumulates byte totals while retaining a bounded stream head.
1330
- *
1331
- * @example
1332
- * ```ts
1333
- * const retention = new Retention()
1334
- * retention.retain(Buffer.from('hello'), 3)?.toString('utf8') // 'hel'
1335
- * retention.retained // 3
1336
- * ```
1337
- */
1338
- var Retention = class {
1339
- #delivered = 0;
1340
- #retained = 0;
1341
- /** The bytes delivered by the stream. */
1342
- get delivered() {
1343
- return this.#delivered;
1344
- }
1345
- /** The bytes retained from the stream head. */
1346
- get retained() {
1347
- return this.#retained;
1348
- }
1891
+ var Session = class {
1892
+ #emitter;
1893
+ #engine;
1894
+ #output;
1349
1895
  /**
1350
- * Retains a delivered chunk within a byte limit.
1896
+ * Spawn one child process and begin byte capture.
1351
1897
  *
1352
- * @param chunk - The delivered chunk, ignored when it is not a buffer
1353
- * @param limit - The maximum retained byte count
1354
- * @returns The retained slice, or `undefined` when the chunk contributes no retained bytes
1898
+ * @param options - Command, workspace, termination, evidence, stdin, and observation settings
1899
+ * @throws A {@link ProcessError} coded `invalid` when an option or command string is malformed
1355
1900
  */
1356
- retain(chunk, limit) {
1357
- if (!node_buffer.Buffer.isBuffer(chunk)) return void 0;
1358
- this.#delivered += chunk.byteLength;
1359
- const room = limit - this.#retained;
1360
- if (room <= 0) return void 0;
1361
- const slice = chunk.byteLength <= room ? chunk : node_buffer.Buffer.from(chunk.subarray(0, room));
1362
- this.#retained += slice.byteLength;
1363
- return slice;
1901
+ constructor(options) {
1902
+ const command = options.command;
1903
+ const workspace = options.workspace;
1904
+ const grace = options.grace;
1905
+ const drain = options.drain;
1906
+ const evidence = options.evidence;
1907
+ const delivery = options.delivery;
1908
+ const signal = options.signal;
1909
+ const on = options.on;
1910
+ const error = options.error;
1911
+ this.#emitter = new _orkestrel_emitter.Emitter({
1912
+ ...on === void 0 ? {} : { on },
1913
+ ...error === void 0 ? {} : { error }
1914
+ });
1915
+ this.#output = this.#publish.bind(this);
1916
+ this.#engine = new Supervisor({
1917
+ command,
1918
+ workspace,
1919
+ writable: true,
1920
+ ...grace === void 0 ? {} : { grace },
1921
+ ...drain === void 0 ? {} : { drain },
1922
+ ...evidence === void 0 ? {} : { evidence },
1923
+ ...delivery === void 0 ? {} : { delivery },
1924
+ ...signal === void 0 ? {} : { signal }
1925
+ }, {
1926
+ chunk: this.#reportStderr.bind(this),
1927
+ fault: this.#reportFault.bind(this),
1928
+ close: this.#closeOutput.bind(this),
1929
+ terminal: this.#reportExit.bind(this),
1930
+ teardown: this.#emitter.destroy.bind(this.#emitter)
1931
+ });
1932
+ this.#engine.stdout.on("data", this.#output);
1364
1933
  }
1365
- };
1366
- //#endregion
1367
- //#region src/server/execution/execute.ts
1368
- /**
1369
- * Runs one command to completion, buffering its output, and settles with the outcome.
1370
- *
1371
- * @remarks
1372
- * The executable is resolved through {@link buildSpawn}, so no run uses a shell. On a POSIX host the
1373
- * child is detached, which is what lets its whole process group be terminated. Standard output and
1374
- * error are each byte-bounded by `limit` (default {@link PROCESS_OUTPUT}) and `truncated` reports
1375
- * whether either stream exceeded it. A positive `timeout` and an aborting `signal` both terminate the
1376
- * child through the same bounded stop, and only the earliest is recorded: a timeout reports
1377
- * `expired` and an abort reports `aborted`, never both. After termination the outcome is awaited for
1378
- * a bounded window, so a descendant holding the child's stdio cannot keep the run pending. That bound
1379
- * covers a terminated run alone: a run with no `timeout` and no `signal` settles on stdio completion
1380
- * rather than on process exit, so a descendant that inherited the child's stdio holds the run open
1381
- * after the child itself has gone. Give such a run a `timeout`. The child's `environment` merges over
1382
- * the parent unless the command is `isolated`, then `options.environment` on top, and `options.input`
1383
- * overrides `command.input`. A host fault while writing that input terminates the run by design and
1384
- * marks its result failed; a strict rejection carries the host fault as its cause. This differs
1385
- * from `Process` constructor input, whose package-initiated input phase stays quiet. Unless `strict`
1386
- * is `false`, a failed run rejects with a {@link ProcessError} carrying the {@link ExecuteResult};
1387
- * {@link createExecuteError} constructs every rejection outside the input-fault door. An invalid
1388
- * option or command string rejects before the child is spawned, because an async function cannot
1389
- * throw synchronously.
1934
+ /** The host process id, fixed when construction returns, or `undefined` when the spawn produced none. */
1935
+ get pid() {
1936
+ return this.#engine.pid;
1937
+ }
1938
+ /** The exit code the host recorded, or `null` while the child has not exited and when a signal ended it. */
1939
+ get code() {
1940
+ return this.#engine.code;
1941
+ }
1942
+ /** The terminating signal name the host recorded, or `null` while the child has not exited and when it exited on its own. */
1943
+ get signal() {
1944
+ return this.#engine.signal;
1945
+ }
1946
+ /** The typed lifecycle observation surface. */
1947
+ get emitter() {
1948
+ return this.#emitter;
1949
+ }
1950
+ /** The decoded byte-bounded stderr tail, frozen at the terminal moment. */
1951
+ get evidence() {
1952
+ return this.#engine.evidence;
1953
+ }
1954
+ /** True after the terminal moment arrived and `exit` settled. */
1955
+ get settled() {
1956
+ return this.#engine.settled;
1957
+ }
1958
+ /** True after termination began, including after the terminal moment. `end` never turns it true. */
1959
+ get stopping() {
1960
+ return this.#engine.stopping;
1961
+ }
1962
+ /** The child's own ending, awaited without the terminal moment's drain window. */
1963
+ get ending() {
1964
+ return this.#engine.ending;
1965
+ }
1966
+ /** The terminal child state, observed once after stream close or the drain cutoff. */
1967
+ get exit() {
1968
+ return this.#engine.exit;
1969
+ }
1970
+ /**
1971
+ * Writes raw bytes to the open standard-input channel.
1972
+ *
1973
+ * @remarks
1974
+ * Never rejects, and appends no terminator: the child receives exactly these bytes. `true` means
1975
+ * the host accepted them without reporting a fault; it does not prove that the child read them.
1976
+ * The channel can queue the payload, so treat the array as owned by the channel until the
1977
+ * returned promise settles.
1978
+ *
1979
+ * @param bytes - The payload to write, already framed by the caller
1980
+ * @returns True when the host accepted the bytes without reporting a fault; false when the channel was closed, destroyed, ended, failed, or remained unconfirmed through `delivery`
1981
+ */
1982
+ write(bytes) {
1983
+ return this.#engine.deliver(bytes);
1984
+ }
1985
+ /**
1986
+ * Closes the standard-input channel and leaves the child running.
1987
+ *
1988
+ * @remarks
1989
+ * Never rejects and terminates nothing: `stopping` stays false and the terminal moment does not
1990
+ * arrive. Every call shares one barrier, which resolves after the host flushes the writes it had
1991
+ * already accepted. A later `write` resolves `false`.
1992
+ *
1993
+ * @returns The stable barrier shared by every call
1994
+ */
1995
+ end() {
1996
+ return this.#engine.end();
1997
+ }
1998
+ /**
1999
+ * Terminates the child process tree and reaches the terminal observation moment.
2000
+ *
2001
+ * @remarks
2002
+ * Never rejects, and every call shares one termination bounded by `grace` and `drain`.
2003
+ *
2004
+ * @returns True when the child's native exit was observed; false when the confirmation deadline elapsed without it
2005
+ */
2006
+ stop() {
2007
+ return this.#engine.stop();
2008
+ }
2009
+ /**
2010
+ * Stops the child and destroys the observation emitter after the terminal state freezes.
2011
+ *
2012
+ * @remarks
2013
+ * Always resolves, including when termination was never confirmed. Every call shares one barrier.
2014
+ *
2015
+ * @returns The stable barrier shared by every call
2016
+ */
2017
+ destroy() {
2018
+ return this.#engine.destroy();
2019
+ }
2020
+ #publish(chunk) {
2021
+ if (!node_buffer.Buffer.isBuffer(chunk) || this.#engine.settled) return;
2022
+ this.#emitter.emit("stdout", new Uint8Array(chunk));
2023
+ }
2024
+ #reportStderr(chunk) {
2025
+ this.#emitter.emit("stderr", chunk);
2026
+ }
2027
+ #reportFault(cause) {
2028
+ this.#emitter.emit("error", cause);
2029
+ }
2030
+ #reportExit(exit) {
2031
+ this.#emitter.emit("exit", exit);
2032
+ }
2033
+ #closeOutput() {
2034
+ this.#engine.stdout.off("data", this.#output);
2035
+ }
2036
+ };
2037
+ //#endregion
2038
+ //#region src/server/factories.ts
2039
+ /**
2040
+ * Creates one supervised child process.
1390
2041
  *
1391
- * @param command - The executable, arguments, and optional environment and input
1392
- * @param options - Working directory, timeout, grace, signal, capture limit, and failure delivery
1393
- * @returns The settled run outcome
1394
- * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
2042
+ * @param options - Command, workspace, termination, evidence, stdin, and observation settings
2043
+ * @returns The launched {@link ProcessInterface}
1395
2044
  *
1396
2045
  * @example
1397
2046
  * ```ts
1398
- * const result = await execute({ file: 'git', arguments: ['status'] }, { workspace: process.cwd() })
2047
+ * import { createProcess } from '@orkestrel/process/server'
2048
+ *
2049
+ * const child = createProcess({
2050
+ * command: { file: 'node', arguments: ['--version'] },
2051
+ * workspace: process.cwd(),
2052
+ * grace: 5000,
2053
+ * })
1399
2054
  * ```
1400
2055
  */
1401
- async function execute(command, options) {
1402
- const snapshot = snapshotCommand(command);
1403
- const optionEnvironment = options?.environment;
1404
- const optionWorkspace = options?.workspace;
1405
- const optionInput = options?.input;
1406
- const optionTimeout = options?.timeout;
1407
- const optionGrace = options?.grace;
1408
- const optionSignal = options?.signal;
1409
- const optionStrict = options?.strict;
1410
- const optionLimit = options?.limit;
1411
- validateCommand(snapshot);
1412
- validateEnvironment(optionEnvironment);
1413
- validateWorkspace(optionWorkspace);
1414
- validateTimer(optionTimeout, "option 'timeout'");
1415
- validateTimer(optionGrace, "option 'grace'");
1416
- validateBytes(optionLimit, "option 'limit'", 0);
1417
- const limit = optionLimit ?? _src_core.PROCESS_OUTPUT;
1418
- const grace = optionGrace ?? _src_core.PROCESS_GRACE;
1419
- const timeout = optionTimeout ?? 0;
1420
- const strict = optionStrict ?? true;
1421
- const text = optionInput ?? snapshot.input;
1422
- const line = formatCommand(snapshot);
1423
- const workspace = optionWorkspace ?? process.cwd();
1424
- const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
1425
- const plan = buildSpawn(snapshot, {
1426
- workspace,
1427
- environment
1428
- });
1429
- const settled = Promise.withResolvers();
1430
- const terminate = new AbortController();
1431
- const cleanup = new AbortController();
1432
- const finish = new AbortController();
1433
- const inputFailure = new AbortController();
1434
- const outChunks = [];
1435
- const errChunks = [];
1436
- const outRetention = new Retention();
1437
- const errRetention = new Retention();
1438
- let spawned = false;
1439
- let expired = false;
1440
- let aborted = false;
1441
- let cause;
1442
- let timeoutTimer;
1443
- let confirmTimer;
1444
- const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
1445
- cwd: workspace,
1446
- detached: process.platform !== "win32",
1447
- env: environment,
1448
- stdio: [
1449
- "pipe",
1450
- "pipe",
1451
- "pipe"
1452
- ],
1453
- windowsHide: true,
1454
- windowsVerbatimArguments: plan.verbatim
1455
- });
1456
- finish.signal.addEventListener("abort", () => {
1457
- clearTimeout(timeoutTimer);
1458
- clearTimeout(confirmTimer);
1459
- cleanup.abort();
1460
- settled.resolve(buildExecuteResult({
1461
- command: line,
1462
- stdout: node_buffer.Buffer.concat(outChunks),
1463
- stderr: node_buffer.Buffer.concat(errChunks),
1464
- code: child.exitCode,
1465
- signal: child.signalCode,
1466
- expired,
1467
- aborted,
1468
- truncated: outRetention.delivered > limit || errRetention.delivered > limit,
1469
- limit,
1470
- ...cause === void 0 ? {} : { cause }
1471
- }));
1472
- }, { once: true });
1473
- terminate.signal.addEventListener("abort", () => {
1474
- if (finish.signal.aborted) return;
1475
- clearTimeout(timeoutTimer);
1476
- cleanup.abort();
1477
- stopChild(child, grace, _src_core.PROCESS_CONFIRMATION).then(() => {
1478
- if (finish.signal.aborted) return;
1479
- confirmTimer = setTimeout(() => finish.abort(), _src_core.PROCESS_CONFIRMATION);
1480
- });
1481
- }, { once: true });
1482
- inputFailure.signal.addEventListener("abort", () => {
1483
- if (finish.signal.aborted) return;
1484
- cause = inputFailure.signal.reason;
1485
- terminate.abort();
1486
- }, { once: true });
1487
- child.stdin.on("error", (error) => inputFailure.abort(error));
1488
- child.stdout.on("data", (chunk) => {
1489
- const retained = outRetention.retain(chunk, limit);
1490
- if (retained !== void 0) outChunks.push(retained);
1491
- });
1492
- child.stderr.on("data", (chunk) => {
1493
- const retained = errRetention.retain(chunk, limit);
1494
- if (retained !== void 0) errChunks.push(retained);
1495
- });
1496
- child.once("spawn", () => {
1497
- spawned = true;
1498
- });
1499
- child.once("error", (error) => {
1500
- if (spawned) return;
1501
- cause = error;
1502
- finish.abort();
1503
- });
1504
- child.once("close", () => finish.abort());
1505
- if (text !== void 0) child.stdin.write(text, (error) => {
1506
- if (error !== void 0 && error !== null) inputFailure.abort(error);
1507
- });
1508
- child.stdin.end();
1509
- if (timeout > 0) timeoutTimer = setTimeout(() => {
1510
- expired = true;
1511
- terminate.abort();
1512
- }, timeout);
1513
- if (optionSignal !== void 0) {
1514
- optionSignal.addEventListener("abort", () => {
1515
- aborted = true;
1516
- terminate.abort();
1517
- }, {
1518
- once: true,
1519
- signal: cleanup.signal
1520
- });
1521
- if (optionSignal.aborted) {
1522
- aborted = true;
1523
- terminate.abort();
1524
- }
1525
- }
1526
- const result = await settled.promise;
1527
- if (result.failed && strict) {
1528
- if (cause !== void 0 && cause === inputFailure.signal.reason) throw new _src_core.ProcessError(`Command '${result.command}' failed while writing standard input`, {
1529
- code: "input",
1530
- context: {
1531
- command: result.command,
1532
- code: result.code,
1533
- signal: result.signal
1534
- },
1535
- cause: inputFailure.signal.reason,
1536
- result
1537
- });
1538
- throw (0, _src_core.createExecuteError)(result, cause);
1539
- }
1540
- return result;
2056
+ function createProcess(options) {
2057
+ return new Process(options);
1541
2058
  }
1542
- //#endregion
1543
- //#region src/server/execution/executeSync.ts
1544
2059
  /**
1545
- * Runs one command to completion synchronously, buffering its output, and returns the outcome.
1546
- *
1547
- * @remarks
1548
- * The synchronous counterpart of {@link execute}, spawned through the same resolver and never through a
1549
- * shell. The host offers no cooperative termination window and no in-flight cancellation, so this
1550
- * contract carries neither. A positive `timeout` ends only the root process and can leave
1551
- * descendants running; use {@link execute} or {@link Process} when timeout must terminate the tree. A
1552
- * timeout and an output overflow both end the root with `SIGKILL`: an overflow reports `truncated`
1553
- * and `failed` together and trims the partial output to `limit`, where {@link execute} keeps reading and
1554
- * reports `truncated` without failing. The environment and input follow the same merge as
1555
- * {@link execute}. Unless `strict` is `false`, a failed run throws a {@link createExecuteError}
1556
- * carrying the {@link ExecuteResult}.
2060
+ * Creates one raw byte session over a supervised child process.
1557
2061
  *
1558
- * @param command - The executable, arguments, and optional environment and input
1559
- * @param options - Working directory, timeout, capture limit, and failure delivery
1560
- * @returns The run outcome
1561
- * @throws A {@link ProcessError} coded `invalid` for a malformed option, command string, or batch-bound argument, or one carrying the {@link ExecuteResult} when the run failed and `strict` is not `false`
2062
+ * @param options - Command, workspace, termination, evidence, stdin, and observation settings
2063
+ * @returns The launched {@link SessionInterface}
1562
2064
  *
1563
2065
  * @example
1564
2066
  * ```ts
1565
- * const result = executeSync({ file: 'git', arguments: ['--version'] }, { strict: false })
2067
+ * import { createSession } from '@orkestrel/process/server'
2068
+ *
2069
+ * const session = createSession({
2070
+ * command: { file: 'node', arguments: ['server.js', '--stdio'] },
2071
+ * workspace: process.cwd(),
2072
+ * grace: 5000,
2073
+ * })
1566
2074
  * ```
1567
2075
  */
1568
- function executeSync(command, options) {
1569
- const snapshot = snapshotCommand(command);
1570
- const optionEnvironment = options?.environment;
1571
- const optionWorkspace = options?.workspace;
1572
- const optionInput = options?.input;
1573
- const optionTimeout = options?.timeout;
1574
- const optionStrict = options?.strict;
1575
- const optionLimit = options?.limit;
1576
- validateCommand(snapshot);
1577
- validateEnvironment(optionEnvironment);
1578
- validateWorkspace(optionWorkspace);
1579
- validateTimer(optionTimeout, "option 'timeout'");
1580
- validateBytes(optionLimit, "option 'limit'", 0);
1581
- const limit = optionLimit ?? _src_core.PROCESS_OUTPUT;
1582
- const timeout = optionTimeout ?? 0;
1583
- const strict = optionStrict ?? true;
1584
- const text = optionInput ?? snapshot.input;
1585
- const line = formatCommand(snapshot);
1586
- const workspace = optionWorkspace ?? process.cwd();
1587
- const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment, optionEnvironment);
1588
- const plan = buildSpawn(snapshot, {
1589
- workspace,
1590
- environment
1591
- });
1592
- const outcome = (0, node_child_process.spawnSync)(plan.file, [...plan.arguments], {
1593
- cwd: workspace,
1594
- env: environment,
1595
- encoding: "buffer",
1596
- maxBuffer: limit,
1597
- killSignal: "SIGKILL",
1598
- windowsHide: true,
1599
- windowsVerbatimArguments: plan.verbatim,
1600
- ...text !== void 0 ? { input: node_buffer.Buffer.from(text) } : {},
1601
- ...timeout > 0 ? { timeout } : {}
1602
- });
1603
- const error = outcome.error;
1604
- const fault = error !== void 0 && "code" in error && (0, _orkestrel_contract.isString)(error.code) ? error.code : "";
1605
- const result = buildExecuteResult({
1606
- command: line,
1607
- stdout: node_buffer.Buffer.isBuffer(outcome.stdout) ? outcome.stdout : node_buffer.Buffer.alloc(0),
1608
- stderr: node_buffer.Buffer.isBuffer(outcome.stderr) ? outcome.stderr : node_buffer.Buffer.alloc(0),
1609
- code: outcome.status,
1610
- signal: outcome.signal,
1611
- expired: fault === "ETIMEDOUT",
1612
- aborted: false,
1613
- truncated: fault === "ENOBUFS",
1614
- limit,
1615
- ...error === void 0 ? {} : { cause: error }
1616
- });
1617
- if (result.failed && strict) throw (0, _src_core.createExecuteError)(result, error);
1618
- return result;
2076
+ function createSession(options) {
2077
+ return new Session(options);
1619
2078
  }
1620
- //#endregion
1621
- //#region src/server/execution/detach.ts
1622
2079
  /**
1623
- * Spawns one command as a detached process and returns without waiting for it.
1624
- *
1625
- * @remarks
1626
- * The child owns no stdio and is unreferenced, so it outlives this process and nothing here observes
1627
- * its outcome. The error listener is attached before the child is unreferenced, so a post-spawn host
1628
- * fault is swallowed rather than crashing the caller; an invalid command is refused before anything
1629
- * is spawned.
2080
+ * Creates one keyed registry of supervised child processes.
1630
2081
  *
1631
- * @param command - The executable, arguments, and optional environment
1632
- * @param options - The working directory the detached child starts in
1633
- * @returns Nothing
1634
- * @throws A {@link ProcessError} coded `invalid` when the working directory or a command string is malformed
2082
+ * @param options - Initial fleet-level observation hooks and listener-error handling
2083
+ * @returns The {@link ProcessManagerInterface} registry
1635
2084
  *
1636
2085
  * @example
1637
2086
  * ```ts
1638
- * detach({ file: 'node', arguments: ['daemon.js'] }, { workspace: process.cwd() })
2087
+ * import { createProcessManager } from '@orkestrel/process/server'
2088
+ *
2089
+ * const manager = createProcessManager()
1639
2090
  * ```
1640
2091
  */
1641
- function detach(command, options) {
1642
- const snapshot = snapshotCommand(command);
1643
- const optionWorkspace = options?.workspace;
1644
- validateCommand(snapshot);
1645
- validateWorkspace(optionWorkspace);
1646
- const workspace = optionWorkspace ?? process.cwd();
1647
- const environment = mergeEnvironment(snapshot.isolated === true, snapshot.environment);
1648
- const plan = buildSpawn(snapshot, {
1649
- workspace,
1650
- environment
1651
- });
1652
- const child = (0, node_child_process.spawn)(plan.file, [...plan.arguments], {
1653
- cwd: workspace,
1654
- detached: true,
1655
- env: environment,
1656
- stdio: "ignore",
1657
- windowsHide: true,
1658
- windowsVerbatimArguments: plan.verbatim
1659
- });
1660
- child.once("error", () => void 0);
1661
- child.unref();
2092
+ function createProcessManager(options) {
2093
+ return new ProcessManager(options);
1662
2094
  }
1663
2095
  //#endregion
1664
2096
  exports.Process = Process;
1665
2097
  exports.ProcessManager = ProcessManager;
1666
- exports.Retention = Retention;
2098
+ exports.Session = Session;
1667
2099
  exports.buildExecutableCandidates = buildExecutableCandidates;
1668
2100
  exports.buildExecuteResult = buildExecuteResult;
1669
2101
  exports.buildPlatformSpawn = buildPlatformSpawn;
1670
2102
  exports.buildSpawn = buildSpawn;
2103
+ exports.captureChunk = captureChunk;
1671
2104
  exports.createProcess = createProcess;
1672
2105
  exports.createProcessManager = createProcessManager;
2106
+ exports.createSession = createSession;
1673
2107
  exports.detach = detach;
1674
2108
  exports.execute = execute;
1675
2109
  exports.executeSync = executeSync;