@sagentlab/navarch-runtime 0.1.116 → 0.1.118

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +43 -17
  2. package/bin/muse-mcp-bridge.cjs +3 -0
  3. package/bin/worktree-guard-hook.cjs +74 -2
  4. package/dist/adapters/index.cjs +6 -1
  5. package/dist/adapters/muse.cjs +433 -0
  6. package/dist/agent-auth.cjs +2 -1
  7. package/dist/config.cjs +8 -1
  8. package/dist/conversation/capability.cjs +1 -1
  9. package/dist/exit-conditions.cjs +1 -1
  10. package/dist/muse-mcp-bridge.cjs +167 -0
  11. package/dist/session.cjs +30 -8
  12. package/node_modules/@muse-code/sdk/LICENSE +21 -0
  13. package/node_modules/@muse-code/sdk/README.md +88 -0
  14. package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
  15. package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
  16. package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
  17. package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
  18. package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
  19. package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
  20. package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
  21. package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
  22. package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
  23. package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
  24. package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
  25. package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
  26. package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
  27. package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
  28. package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
  29. package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
  30. package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
  31. package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
  32. package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
  33. package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
  34. package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
  35. package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
  36. package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
  37. package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
  38. package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
  39. package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
  40. package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
  41. package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
  42. package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
  43. package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
  44. package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
  45. package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
  46. package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
  47. package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
  48. package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
  49. package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
  50. package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
  51. package/node_modules/@muse-code/sdk/package.json +47 -0
  52. package/package.json +8 -4
@@ -0,0 +1,188 @@
1
+ /** Node stdio binding and type-state handshake for the MSP connection. */
2
+ import { spawn } from "node:child_process";
3
+ import type { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from "node:child_process";
4
+ import type { InitializeParams, InitializeResult } from "../msp.js";
5
+ import { Connection } from "./connection.js";
6
+ import type { ConnectionOptions, DuplexTransport, NotificationHandler, ProtocolErrorHandler, ServerRequestHandler } from "./connection.js";
7
+ import type { FingerprintWarning } from "../fingerprint.js";
8
+ /**
9
+ * What `spawnMspConnection` needs to launch a host: the command to run, plus
10
+ * optional arguments, working directory, environment, connection tuning, a
11
+ * stderr tap, and the shutdown drain budget.
12
+ */
13
+ export interface SpawnMspConnectionOptions {
14
+ readonly command: string;
15
+ readonly args?: readonly string[];
16
+ readonly cwd?: string;
17
+ readonly env?: NodeJS.ProcessEnv;
18
+ readonly connection?: ConnectionOptions;
19
+ /** Raw stderr chunks, drained from process birth and never parsed. */
20
+ readonly onStderr?: (chunk: string) => void;
21
+ /** See {@link MuseServeChildOptions.shutdownTimeoutMs}. */
22
+ readonly shutdownTimeoutMs?: number;
23
+ }
24
+ /**
25
+ * How the host process ended: its exit code, or the signal that ended it.
26
+ * Exactly one of the two is non-null.
27
+ */
28
+ export interface ProcessExit {
29
+ readonly code: number | null;
30
+ readonly signal: NodeJS.Signals | null;
31
+ }
32
+ /**
33
+ * The SDK's reading of a host exit. Branch on `kind`: a clean shutdown, a
34
+ * crash, an unhandled error, or one of the documented error exits. Every arm
35
+ * but `cleanShutdown` carries its exit evidence (code or signal plus a
36
+ * bounded stderr tail), and, where the meaning is known, whether retrying
37
+ * can help.
38
+ */
39
+ export type ExitClassification = {
40
+ readonly kind: "cleanShutdown";
41
+ } | {
42
+ readonly kind: "unhandledError";
43
+ readonly exitCode: number;
44
+ readonly stderrTail: readonly string[];
45
+ } | {
46
+ readonly kind: "usageError" | "configError" | "leaseUnavailable" | "sdkSurfaceUnavailable";
47
+ readonly exitCode: number;
48
+ readonly stderrTail: readonly string[];
49
+ readonly retry: "never" | "fix-config" | "after-lease-release";
50
+ } | {
51
+ readonly kind: "crash";
52
+ readonly exitCode: number | null;
53
+ readonly exitSignal: string | null;
54
+ readonly stderrTail: readonly string[];
55
+ };
56
+ /**
57
+ * Options for launching an SDK-owned host process: the binary to run, plus
58
+ * optional arguments, working directory, environment, a stderr tap, and the
59
+ * shutdown drain budget.
60
+ */
61
+ export interface MuseServeChildOptions {
62
+ readonly museBin: string;
63
+ readonly args?: readonly string[];
64
+ readonly cwd?: string;
65
+ readonly env?: NodeJS.ProcessEnv;
66
+ /** Raw evidence callback for consumers that also keep a full diagnostic log. */
67
+ readonly onStderr?: (chunk: string) => void;
68
+ /**
69
+ * How long `close()` lets the host drain after stdin EOF before the SDK
70
+ * ends the process it owns. Defaults to SS2.1.2's 30 s host drain timeout;
71
+ * set it to match a host configured with a different one. `0` skips the
72
+ * drain window (SIGTERM at once) — it is NOT an instant kill: the fixed
73
+ * SIGTERM→SIGKILL grace still runs. Must be an integer in
74
+ * `0..2147483647`; anything else throws `RangeError` at spawn.
75
+ */
76
+ readonly shutdownTimeoutMs?: number;
77
+ /**
78
+ * Deterministic-test seam; production always uses `child_process.spawn`.
79
+ * A stub here observes the exact options the call site passes, so a
80
+ * `windowsHide` override at the call site fails the pin (FR-45141-1).
81
+ */
82
+ readonly spawnFn?: typeof spawn;
83
+ }
84
+ declare const ownedTransport: unique symbol;
85
+ declare const adoptFlushSource: unique symbol;
86
+ /**
87
+ * Module friend seam: did `initialize` get a usable answer before the
88
+ * handshake failed? Exported from this MODULE only (never the package
89
+ * barrel) so the facade can word a host lost AFTER answering truthfully
90
+ * (#49912) without a new public member on `MspHandshake`.
91
+ */
92
+ export declare const initializeAnswered: unique symbol;
93
+ /**
94
+ * Shutdown deadline contract: `expired` resolves and never rejects; after
95
+ * `clear()` it may remain pending forever.
96
+ */
97
+ interface ShutdownDeadline {
98
+ readonly expired: Promise<void>;
99
+ clear(): void;
100
+ }
101
+ type ShutdownDeadlineFactory = (budgetMs: number) => ShutdownDeadline;
102
+ /**
103
+ * Is this `stdin.end()` error the benign close race — the child exited
104
+ * between the liveness check and the end() call, so its stdin is already
105
+ * gone? That IS the closed end-state close() wants (SS2.11: the caller
106
+ * still awaits `exited` and gates on the exit code); anything else is a
107
+ * real failure. `ECANCELED` is the same owned-teardown race one stage later:
108
+ * the SIGTERM->SIGKILL backstop destroys stdin while a wedged write is still
109
+ * queued, and libuv cancels that write instead of erroring it. Exported only
110
+ * for its deterministic unit test.
111
+ */
112
+ export declare function isBenignCloseRace(error: unknown): boolean;
113
+ /**
114
+ * Internal transport over an already-spawned child's stdio. Exported from
115
+ * this MODULE only (never the package barrel) so the deterministic
116
+ * close()-race tests can construct it around a stub child — the live arm
117
+ * awaits `exited` first and cannot reach the race branch (PR #15641 review).
118
+ */
119
+ export declare class ChildStdioTransport implements DuplexTransport {
120
+ #private;
121
+ readonly child: ChildProcessWithoutNullStreams;
122
+ readonly incoming: AsyncIterable<string>;
123
+ readonly exited: Promise<ProcessExit>;
124
+ constructor(child: ChildProcessWithoutNullStreams, onStderr?: (chunk: string) => void, shutdownTimeoutMs?: number, ownsProcessGroup?: boolean,
125
+ /** Deterministic-test seam; production always uses `makeShutdownDeadline`. */
126
+ deadlineFactory?: ShutdownDeadlineFactory);
127
+ write(chunk: string): Promise<void>;
128
+ /**
129
+ * The single owner of shutdown (#15943). EOF first, then bounded: SS2.1.2
130
+ * gives the host a drain window and says the remainder past it has crash
131
+ * semantics, so past the window the SDK ends the process it owns rather
132
+ * than waiting on an EOF that may never come. Memoized, so concurrent and
133
+ * repeated calls share one attempt and one set of timers.
134
+ */
135
+ /** Module-private friend seam used by the handshake that owns both sides. */
136
+ [adoptFlushSource](source: () => Promise<void>): void;
137
+ close(flushed?: Promise<void>): Promise<void>;
138
+ }
139
+ /**
140
+ * The exact options `MuseServeChild.spawn` hands to `child_process.spawn`,
141
+ * factored out so the spawn-options pin can assert on them without spawning.
142
+ * Exported from this MODULE only (never the package barrel) — the same
143
+ * module-only-test-seam precedent as `isBenignCloseRace`.
144
+ */
145
+ export declare function buildSpawnOptions(options: {
146
+ readonly cwd?: string;
147
+ readonly env?: NodeJS.ProcessEnv;
148
+ readonly detached: boolean;
149
+ }): SpawnOptionsWithoutStdio;
150
+ /** One owned MSP host process with SS2.11 diagnostics and total exit mapping. */
151
+ export declare class MuseServeChild {
152
+ #private;
153
+ readonly exit: Promise<ExitClassification>;
154
+ private constructor();
155
+ static spawn(options: MuseServeChildOptions): MuseServeChild;
156
+ get stderrTail(): readonly string[];
157
+ /** Module-private friend seam used by the handshake machine for this child. */
158
+ [ownedTransport](): ChildStdioTransport;
159
+ close(): Promise<ExitClassification>;
160
+ }
161
+ /** The only public state before `initialized`: callers cannot send traffic. */
162
+ export declare class MspHandshake {
163
+ #private;
164
+ readonly child: MuseServeChild;
165
+ constructor(options: SpawnMspConnectionOptions);
166
+ get exited(): Promise<ProcessExit>;
167
+ onServerRequest(handler: ServerRequestHandler): void;
168
+ onNotification(handler: NotificationHandler): void;
169
+ onProtocolError(handler: ProtocolErrorHandler): void;
170
+ close(): Promise<ProcessExit>;
171
+ /** See {@link initializeAnswered}. */
172
+ [initializeAnswered](): boolean;
173
+ initialize(params: InitializeParams): Promise<SpawnedMspConnection>;
174
+ }
175
+ /** A fully initialized connection plus its owned process boundary. */
176
+ export declare class SpawnedMspConnection {
177
+ #private;
178
+ readonly connection: Connection;
179
+ readonly child: MuseServeChild;
180
+ readonly initializeResult: InitializeResult;
181
+ readonly fingerprintWarning: FingerprintWarning | undefined;
182
+ readonly exited: Promise<ProcessExit>;
183
+ constructor(connection: Connection, transport: ChildStdioTransport, child: MuseServeChild, initializeResult: InitializeResult, fingerprintWarning: FingerprintWarning | undefined);
184
+ close(): Promise<ProcessExit>;
185
+ }
186
+ /** Spawn one owned host and begin its SS1.4 handshake state machine. */
187
+ export declare function spawnMspConnection(options: SpawnMspConnectionOptions): MspHandshake;
188
+ export {};
@@ -0,0 +1,496 @@
1
+ /** Node stdio binding and type-state handshake for the MSP connection. */
2
+ import { spawn } from "node:child_process";
3
+ import { Connection, ProtocolError, requestWithFrame, submissionTail, } from "./connection.js";
4
+ import { checkServedFingerprint } from "../fingerprint.js";
5
+ const DEFAULT_STDERR_MAX_BYTES = 8 * 1024;
6
+ const DEFAULT_STDERR_MAX_LINES = 100;
7
+ /** SS2.1.2's default host drain timeout: the whole EOF sequence's budget. */
8
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
9
+ /**
10
+ * SIGTERM → SIGKILL grace. Not a knob: by the time it runs the host has
11
+ * already had its whole drain window, so this covers only a signal handler's
12
+ * final flush. Codex's stdio launcher uses the same two seconds.
13
+ */
14
+ const SIGTERM_GRACE_MS = 2_000;
15
+ /** Node's own `setTimeout` ceiling; past it a delay silently clamps to 1 ms. */
16
+ const MAX_SHUTDOWN_TIMEOUT_MS = 2_147_483_647;
17
+ const ownedTransport = Symbol("MuseServeChild.transport");
18
+ const adoptFlushSource = Symbol("ChildStdioTransport.adoptFlushSource");
19
+ /**
20
+ * Module friend seam: did `initialize` get a usable answer before the
21
+ * handshake failed? Exported from this MODULE only (never the package
22
+ * barrel) so the facade can word a host lost AFTER answering truthfully
23
+ * (#49912) without a new public member on `MspHandshake`.
24
+ */
25
+ export const initializeAnswered = Symbol("MspHandshake.initializeAnswered");
26
+ function makeShutdownDeadline(budgetMs) {
27
+ let timer;
28
+ const expired = new Promise((resolve) => {
29
+ timer = setTimeout(resolve, budgetMs);
30
+ });
31
+ return {
32
+ expired,
33
+ clear: () => {
34
+ if (timer !== undefined)
35
+ clearTimeout(timer);
36
+ timer = undefined;
37
+ },
38
+ };
39
+ }
40
+ /** A client-local evidence budget. Its contents never influence behavior. */
41
+ class StderrTail {
42
+ #text = "";
43
+ push(chunk) {
44
+ this.#text += chunk;
45
+ this.#trimLines();
46
+ this.#trimBytes();
47
+ }
48
+ lines() {
49
+ if (this.#text.length === 0)
50
+ return [];
51
+ const lines = this.#text.split("\n");
52
+ if (lines.at(-1) === "")
53
+ lines.pop();
54
+ return lines;
55
+ }
56
+ #trimLines() {
57
+ let lineCount = this.#text.split("\n").length;
58
+ if (this.#text.endsWith("\n"))
59
+ lineCount -= 1;
60
+ while (lineCount > DEFAULT_STDERR_MAX_LINES) {
61
+ const firstBreak = this.#text.indexOf("\n");
62
+ if (firstBreak < 0)
63
+ break;
64
+ this.#text = this.#text.slice(firstBreak + 1);
65
+ lineCount -= 1;
66
+ }
67
+ }
68
+ #trimBytes() {
69
+ const bytes = Buffer.from(this.#text, "utf8");
70
+ if (bytes.byteLength <= DEFAULT_STDERR_MAX_BYTES)
71
+ return;
72
+ let start = bytes.byteLength - DEFAULT_STDERR_MAX_BYTES;
73
+ // If the byte budget cuts through a UTF-8 scalar, advance to its first
74
+ // complete successor rather than manufacturing replacement text.
75
+ while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80)
76
+ start += 1;
77
+ this.#text = bytes.subarray(start).toString("utf8");
78
+ }
79
+ }
80
+ function classifyExit(processExit, stderrTail) {
81
+ const evidence = [...stderrTail];
82
+ switch (processExit.code) {
83
+ case 0:
84
+ return { kind: "cleanShutdown" };
85
+ case 1:
86
+ return { kind: "unhandledError", exitCode: 1, stderrTail: evidence };
87
+ case 2:
88
+ return { kind: "usageError", exitCode: 2, stderrTail: evidence, retry: "never" };
89
+ case 3:
90
+ return { kind: "configError", exitCode: 3, stderrTail: evidence, retry: "fix-config" };
91
+ case 4:
92
+ // Exit 4 is `sessionInUse` arriving early: another live client holds the
93
+ // lease, and the lease frees itself once that client exits (SS2.11).
94
+ return {
95
+ kind: "leaseUnavailable",
96
+ exitCode: 4,
97
+ stderrTail: evidence,
98
+ retry: "after-lease-release",
99
+ };
100
+ case 5:
101
+ return {
102
+ kind: "sdkSurfaceUnavailable",
103
+ exitCode: 5,
104
+ stderrTail: evidence,
105
+ retry: "never",
106
+ };
107
+ default:
108
+ return {
109
+ kind: "crash",
110
+ exitCode: processExit.code,
111
+ exitSignal: processExit.signal,
112
+ stderrTail: evidence,
113
+ };
114
+ }
115
+ }
116
+ /**
117
+ * Is this `stdin.end()` error the benign close race — the child exited
118
+ * between the liveness check and the end() call, so its stdin is already
119
+ * gone? That IS the closed end-state close() wants (SS2.11: the caller
120
+ * still awaits `exited` and gates on the exit code); anything else is a
121
+ * real failure. `ECANCELED` is the same owned-teardown race one stage later:
122
+ * the SIGTERM->SIGKILL backstop destroys stdin while a wedged write is still
123
+ * queued, and libuv cancels that write instead of erroring it. Exported only
124
+ * for its deterministic unit test.
125
+ */
126
+ export function isBenignCloseRace(error) {
127
+ const code = error?.code;
128
+ return code === "ERR_STREAM_DESTROYED" || code === "EPIPE" || code === "ECANCELED";
129
+ }
130
+ /**
131
+ * Node clamps a `setTimeout` delay that is non-finite or > 2^31-1 down to
132
+ * 1 ms, so an out-of-range budget would silently become "kill almost
133
+ * immediately" and fabricate a crash row for a host that was draining
134
+ * normally — a row FM-002's ephemeral discard then acts on (PR #22819
135
+ * review). Refuse it at the owning boundary instead of clamping.
136
+ */
137
+ function assertShutdownTimeout(shutdownTimeoutMs) {
138
+ if (!Number.isInteger(shutdownTimeoutMs) ||
139
+ shutdownTimeoutMs < 0 ||
140
+ shutdownTimeoutMs > MAX_SHUTDOWN_TIMEOUT_MS) {
141
+ throw new RangeError(`shutdownTimeoutMs must be an integer in 0..${MAX_SHUTDOWN_TIMEOUT_MS}, got ${String(shutdownTimeoutMs)}`);
142
+ }
143
+ }
144
+ /**
145
+ * Internal transport over an already-spawned child's stdio. Exported from
146
+ * this MODULE only (never the package barrel) so the deterministic
147
+ * close()-race tests can construct it around a stub child — the live arm
148
+ * awaits `exited` first and cannot reach the race branch (PR #15641 review).
149
+ */
150
+ export class ChildStdioTransport {
151
+ child;
152
+ incoming;
153
+ exited;
154
+ #shutdownTimeoutMs;
155
+ #makeShutdownDeadline;
156
+ // True only when the boundary that SPAWNED this child made it a process-
157
+ // group leader (FR-017b). Never inferred from the child itself: a test
158
+ // handing the transport an ordinary child keeps the direct-kill path.
159
+ #ownsProcessGroup;
160
+ #closing;
161
+ // The default `flushed` for closes routed AROUND the connection — notably
162
+ // the public `child.close()`. Without it, step 0 existed only for
163
+ // Connection-routed closes while the contract claimed all three surfaces
164
+ // behaved identically, and `#closing ??=` let whichever call won the race
165
+ // discard the other's submission promise (PR #22819 round 4).
166
+ #defaultFlushed;
167
+ constructor(child, onStderr, shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS, ownsProcessGroup = false,
168
+ /** Deterministic-test seam; production always uses `makeShutdownDeadline`. */
169
+ deadlineFactory = makeShutdownDeadline) {
170
+ this.child = child;
171
+ assertShutdownTimeout(shutdownTimeoutMs);
172
+ this.#shutdownTimeoutMs = shutdownTimeoutMs;
173
+ this.#ownsProcessGroup = ownsProcessGroup;
174
+ this.#makeShutdownDeadline = deadlineFactory;
175
+ this.child.stdout.setEncoding("utf8");
176
+ this.child.stderr.setEncoding("utf8");
177
+ this.child.stderr.on("data", (chunk) => onStderr?.(chunk));
178
+ // Node delivers a failed stdin write to BOTH the write callback and a
179
+ // later 'error' event; with no listener that event kills the embedding
180
+ // process (FM-001). The permanent no-op absorbs it — the write/end
181
+ // callbacks below carry the same error to the real rejection path.
182
+ this.child.stdin.on("error", () => { });
183
+ this.incoming = this.#stdoutChunks();
184
+ this.exited = new Promise((resolve, reject) => {
185
+ this.child.once("error", reject);
186
+ this.child.once("close", (code, signal) => resolve({ code, signal }));
187
+ });
188
+ // Mark handled at birth: a spawn failure (e.g. ENOENT) rejects `exited`
189
+ // before anyone can await it, and the default unhandled-rejection policy
190
+ // would kill the consumer. Later awaiters still receive the rejection.
191
+ this.exited.catch(() => { });
192
+ }
193
+ async write(chunk) {
194
+ if (this.child.stdin.destroyed)
195
+ throw new ProtocolError("spawned MSP host stdin is closed");
196
+ await new Promise((resolve, reject) => {
197
+ this.child.stdin.write(chunk, "utf8", (error) => (error ? reject(error) : resolve()));
198
+ });
199
+ }
200
+ /**
201
+ * The single owner of shutdown (#15943). EOF first, then bounded: SS2.1.2
202
+ * gives the host a drain window and says the remainder past it has crash
203
+ * semantics, so past the window the SDK ends the process it owns rather
204
+ * than waiting on an EOF that may never come. Memoized, so concurrent and
205
+ * repeated calls share one attempt and one set of timers.
206
+ */
207
+ /** Module-private friend seam used by the handshake that owns both sides. */
208
+ [adoptFlushSource](source) {
209
+ this.#defaultFlushed = source;
210
+ }
211
+ async close(flushed) {
212
+ this.#closing ??= this.#shutdown(flushed ?? this.#defaultFlushed?.());
213
+ await this.#closing;
214
+ }
215
+ async #shutdown(flushed) {
216
+ // ONE budget covers the whole shutdown, flush wait included, so handing
217
+ // the connection's submission promise in here cannot double the bound.
218
+ const deadline = this.#makeShutdownDeadline(this.#shutdownTimeoutMs);
219
+ try {
220
+ // Frames the connection accepted before close() must reach the stream
221
+ // ahead of EOF (PR #22819 round 3); a peer that never drains simply
222
+ // spends the drain window here instead of after EOF.
223
+ if (flushed !== undefined)
224
+ await this.#beforeDeadline(flushed, deadline.expired);
225
+ return await this.#shutdownAfterFlush(deadline.expired);
226
+ }
227
+ finally {
228
+ deadline.clear();
229
+ }
230
+ }
231
+ async #shutdownAfterFlush(deadline) {
232
+ // The EOF attempt must NEVER gate the ladder (PR #22819 review, P0). Two
233
+ // reachable paths leave it unsettled forever, and both are the #15943
234
+ // wedge itself: a host that stopped READING stdin never flushes a filled
235
+ // pipe, so `end()`'s callback never fires; and a non-benign end() error
236
+ // would reject here, before a single signal is sent — memoized, so every
237
+ // later close() replays that rejection with the child still alive. Start
238
+ // the clock regardless and await the EOF outcome AFTER the child is gone,
239
+ // where a pending write settles as the benign EPIPE race and a real error
240
+ // still surfaces to the caller.
241
+ const eof = this.#endStdin();
242
+ eof.catch(() => { });
243
+ await this.#awaitExitOrTerminate(deadline);
244
+ await eof;
245
+ }
246
+ async #endStdin() {
247
+ if (this.child.stdin.destroyed || this.child.stdin.writableEnded)
248
+ return;
249
+ await new Promise((resolve, reject) => {
250
+ // The destroyed/writableEnded guard above is racy: the child can exit
251
+ // between it and end(), and Node then reports ERR_STREAM_DESTROYED
252
+ // asynchronously (seen on the slower CI runners driving the real
253
+ // serve-fixture child, PR #15641). That race is the end-state close()
254
+ // wants, so it resolves; a real error still rejects.
255
+ this.child.stdin.end((error) => error === null || error === undefined || isBenignCloseRace(error)
256
+ ? resolve()
257
+ : reject(error));
258
+ });
259
+ }
260
+ /**
261
+ * Escalate only as far as the host forces. Every stage ends at the child's
262
+ * REAL exit, so `exited` — and every classification derived from it — stays
263
+ * a function of the observed `(code, signal)` pair (INV-011). A signal is a
264
+ * way to reach an exit, never a substitute for observing one.
265
+ */
266
+ async #awaitExitOrTerminate(deadline) {
267
+ if (await this.#beforeDeadline(this.#settled(), deadline))
268
+ return;
269
+ this.#signal("SIGTERM");
270
+ if (await this.#settledWithin(SIGTERM_GRACE_MS))
271
+ return;
272
+ // Past SIGTERM, drop our end of the pipe ourselves (PR #22819 round 3).
273
+ // Nothing else does, so a write the host never drained keeps `stdin`
274
+ // open — and the child's `close` event, which `exited` and the trailing
275
+ // EOF await both wait on, needs the stdio closed. Waiting for that write
276
+ // to error out on its own is the last unbounded stage; this removes it.
277
+ this.child.stdin.destroy();
278
+ this.#signal("SIGKILL");
279
+ await this.#settled();
280
+ }
281
+ /**
282
+ * One signal per escalation stage, delivered to the whole owned process
283
+ * group where this spawn created one (FR-017b, #22777). The child PID
284
+ * alone is not enough: a grandchild that inherited the host's stdout
285
+ * keeps the `close` event — which `exited` and every stage here wait
286
+ * on — from firing even after the child itself is gone, and it never
287
+ * receives a child-targeted signal at all. A group signal that fails for
288
+ * any reason (expected: `ESRCH`, the whole group already exited between
289
+ * stages) falls through to the direct path, which Node makes a no-op on
290
+ * an exited child — the ladder must never rethrow out of a stage.
291
+ */
292
+ #signal(signal) {
293
+ const pid = this.child.pid;
294
+ if (this.#ownsProcessGroup && pid !== undefined) {
295
+ try {
296
+ process.kill(-pid, signal);
297
+ return;
298
+ }
299
+ catch {
300
+ // Fall through to the direct-child path, safe on every failure here.
301
+ }
302
+ }
303
+ this.child.kill(signal);
304
+ }
305
+ /** True once `exited` settles either way — a rejection means no live child. */
306
+ async #settledWithin(budgetMs) {
307
+ return await this.#within(this.#settled(), budgetMs);
308
+ }
309
+ /** True if `work` settles before the one shared shutdown deadline fires. */
310
+ async #beforeDeadline(work, deadline) {
311
+ return await Promise.race([
312
+ work.then(() => true, () => true),
313
+ deadline.then(() => false),
314
+ ]);
315
+ }
316
+ /** True if `work` settles inside `budgetMs`; false when the budget expires. */
317
+ async #within(work, budgetMs) {
318
+ // Deliberately ambient (NOT this.#makeShutdownDeadline): the SIGTERM grace
319
+ // window is outside the one shared shutdown budget the injected seam models.
320
+ const deadline = makeShutdownDeadline(budgetMs);
321
+ try {
322
+ return await this.#beforeDeadline(work, deadline.expired);
323
+ }
324
+ finally {
325
+ deadline.clear();
326
+ }
327
+ }
328
+ /**
329
+ * `exited` REJECTS when the spawn itself failed (ENOENT). There is no
330
+ * process to end and no exit to observe, so shutdown is complete; the
331
+ * rejection still reaches the caller through its own `await exited`
332
+ * (`MuseServeChild.close()`, `MspHandshake.close()`), unchanged.
333
+ */
334
+ async #settled() {
335
+ await this.exited.catch(() => { });
336
+ }
337
+ async *#stdoutChunks() {
338
+ for await (const chunk of this.child.stdout)
339
+ yield String(chunk);
340
+ }
341
+ }
342
+ /**
343
+ * The exact options `MuseServeChild.spawn` hands to `child_process.spawn`,
344
+ * factored out so the spawn-options pin can assert on them without spawning.
345
+ * Exported from this MODULE only (never the package barrel) — the same
346
+ * module-only-test-seam precedent as `isBenignCloseRace`.
347
+ */
348
+ export function buildSpawnOptions(options) {
349
+ return {
350
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
351
+ ...(options.env === undefined ? {} : { env: options.env }),
352
+ detached: options.detached,
353
+ // FR-45141-1: `muse serve` is never meant to be seen. Without this, a
354
+ // Windows host with no console of its own pops a console window for the
355
+ // life of the connection; the flag is a no-op on other platforms.
356
+ windowsHide: true,
357
+ };
358
+ }
359
+ /** One owned MSP host process with SS2.11 diagnostics and total exit mapping. */
360
+ export class MuseServeChild {
361
+ #tail = new StderrTail();
362
+ #transport;
363
+ exit;
364
+ constructor(child, onStderr, shutdownTimeoutMs, ownsProcessGroup = false) {
365
+ this.#transport = new ChildStdioTransport(child, (chunk) => {
366
+ this.#tail.push(chunk);
367
+ onStderr?.(chunk);
368
+ }, shutdownTimeoutMs, ownsProcessGroup);
369
+ this.exit = this.#transport.exited.then((status) => classifyExit(status, this.#tail.lines()));
370
+ // A spawn error can reject before a consumer attaches its await. Mark the
371
+ // derived promise handled at birth without changing what later awaiters see.
372
+ this.exit.catch(() => { });
373
+ }
374
+ static spawn(options) {
375
+ // Validate BEFORE spawning: a throw from the transport constructor would
376
+ // otherwise leave the child it was meant to own already running.
377
+ if (options.shutdownTimeoutMs !== undefined)
378
+ assertShutdownTimeout(options.shutdownTimeoutMs);
379
+ // POSIX: own a whole process group, not just the child (FR-017b,
380
+ // #22777). `detached` makes the host the leader of a new group (and
381
+ // session), so escalation can end a grandchild holding the inherited
382
+ // stdout; stdio stays piped, so nothing else about ownership changes.
383
+ // Windows keeps the child-only path — group signalling via a negative
384
+ // PID is a POSIX primitive, and pretending otherwise would fake
385
+ // subtree containment there.
386
+ const ownsProcessGroup = process.platform !== "win32";
387
+ const spawnOptions = buildSpawnOptions({
388
+ cwd: options.cwd,
389
+ env: options.env,
390
+ detached: ownsProcessGroup,
391
+ });
392
+ const spawnChild = options.spawnFn ?? spawn;
393
+ return new MuseServeChild(spawnChild(options.museBin, [...(options.args ?? [])], spawnOptions), options.onStderr, options.shutdownTimeoutMs, ownsProcessGroup);
394
+ }
395
+ get stderrTail() {
396
+ return this.#tail.lines();
397
+ }
398
+ /** Module-private friend seam used by the handshake machine for this child. */
399
+ [ownedTransport]() {
400
+ return this.#transport;
401
+ }
402
+ async close() {
403
+ await this.#transport.close();
404
+ return await this.exit;
405
+ }
406
+ }
407
+ /** The only public state before `initialized`: callers cannot send traffic. */
408
+ export class MspHandshake {
409
+ #connection;
410
+ child;
411
+ #transport;
412
+ #started = false;
413
+ #answered = false;
414
+ constructor(options) {
415
+ this.child = MuseServeChild.spawn({
416
+ museBin: options.command,
417
+ ...(options.args === undefined ? {} : { args: options.args }),
418
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
419
+ ...(options.env === undefined ? {} : { env: options.env }),
420
+ ...(options.onStderr === undefined ? {} : { onStderr: options.onStderr }),
421
+ ...(options.shutdownTimeoutMs === undefined
422
+ ? {}
423
+ : { shutdownTimeoutMs: options.shutdownTimeoutMs }),
424
+ });
425
+ this.#transport = this.child[ownedTransport]();
426
+ this.#connection = new Connection(this.#transport, options.connection);
427
+ // Make step 0 true on ALL THREE close surfaces, not only this class's:
428
+ // `child.close()` and `spawned.child.close()` are public, and without
429
+ // this they ended the host's input while a just-accepted frame was still
430
+ // queued on a microtask (PR #22819 round 4).
431
+ this.#transport[adoptFlushSource](() => this.#connection[submissionTail]());
432
+ }
433
+ get exited() {
434
+ return this.#transport.exited;
435
+ }
436
+ onServerRequest(handler) {
437
+ this.#connection.onServerRequest(handler);
438
+ }
439
+ onNotification(handler) {
440
+ this.#connection.onNotification(handler);
441
+ }
442
+ onProtocolError(handler) {
443
+ this.#connection.onProtocolError(handler);
444
+ }
445
+ async close() {
446
+ await this.#connection.close();
447
+ return await this.#transport.exited;
448
+ }
449
+ /** See {@link initializeAnswered}. */
450
+ [initializeAnswered]() {
451
+ return this.#answered;
452
+ }
453
+ async initialize(params) {
454
+ if (this.#started)
455
+ throw new ProtocolError("initialize may be sent only once per connection");
456
+ this.#started = true;
457
+ const { result: raw, line } = await this.#connection[requestWithFrame]("initialize", params);
458
+ this.#answered = true;
459
+ const result = raw;
460
+ if (typeof result.schema?.fingerprint !== "string") {
461
+ // The whole refused frame rides on the error, as on every other
462
+ // refusal: a host that answered with an unusable result was not lost,
463
+ // and the facade tells the two apart by it (#49912, #50588 review).
464
+ throw new ProtocolError("initialize result has no schema fingerprint", line);
465
+ }
466
+ const fingerprintWarning = checkServedFingerprint(result.schema.fingerprint);
467
+ this.#connection.notify("initialized");
468
+ await this.#connection.flush();
469
+ return new SpawnedMspConnection(this.#connection, this.#transport, this.child, result, fingerprintWarning);
470
+ }
471
+ }
472
+ /** A fully initialized connection plus its owned process boundary. */
473
+ export class SpawnedMspConnection {
474
+ connection;
475
+ child;
476
+ initializeResult;
477
+ fingerprintWarning;
478
+ exited;
479
+ #transport;
480
+ constructor(connection, transport, child, initializeResult, fingerprintWarning) {
481
+ this.connection = connection;
482
+ this.#transport = transport;
483
+ this.child = child;
484
+ this.initializeResult = initializeResult;
485
+ this.fingerprintWarning = fingerprintWarning;
486
+ this.exited = transport.exited;
487
+ }
488
+ async close() {
489
+ await this.connection.close();
490
+ return await this.#transport.exited;
491
+ }
492
+ }
493
+ /** Spawn one owned host and begin its SS1.4 handshake state machine. */
494
+ export function spawnMspConnection(options) {
495
+ return new MspHandshake(options);
496
+ }