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