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