@sagentlab/navarch-runtime 0.1.116 → 0.1.117

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 (51) hide show
  1. package/README.md +43 -17
  2. package/bin/muse-mcp-bridge.cjs +3 -0
  3. package/dist/adapters/index.cjs +6 -1
  4. package/dist/adapters/muse.cjs +433 -0
  5. package/dist/agent-auth.cjs +2 -1
  6. package/dist/config.cjs +8 -1
  7. package/dist/conversation/capability.cjs +1 -1
  8. package/dist/exit-conditions.cjs +1 -1
  9. package/dist/muse-mcp-bridge.cjs +167 -0
  10. package/dist/session.cjs +30 -8
  11. package/node_modules/@muse-code/sdk/LICENSE +21 -0
  12. package/node_modules/@muse-code/sdk/README.md +88 -0
  13. package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
  14. package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
  15. package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
  16. package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
  17. package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
  18. package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
  19. package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
  20. package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
  21. package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
  22. package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
  23. package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
  24. package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
  25. package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
  26. package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
  27. package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
  28. package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
  29. package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
  30. package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
  31. package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
  32. package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
  33. package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
  34. package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
  35. package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
  36. package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
  37. package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
  38. package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
  39. package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
  40. package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
  41. package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
  42. package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
  43. package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
  44. package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
  45. package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
  46. package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
  47. package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
  48. package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
  49. package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
  50. package/node_modules/@muse-code/sdk/package.json +47 -0
  51. package/package.json +8 -4
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Duplex-agnostic MSP NDJSON connection (spec 14990 FR-012/014/015/016).
3
+ *
4
+ * The transport supplies decoded UTF-8 chunks. Chunks may be empty and may
5
+ * split anywhere — including mid surrogate pair: the byte accounting below
6
+ * reconciles split pairs (FR-012(a)), so transports need not align chunks
7
+ * to character boundaries. The Node child-process binding — which sets
8
+ * stdout's encoding before implementing this interface — is one
9
+ * well-behaved transport, not the general guarantee.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ const DEFAULT_FRAME_LIMIT_BYTES = 10_485_760;
13
+ /**
14
+ * Module-private friend seam (never on the package barrel, like spawn.ts's
15
+ * `ownedTransport`): the submission tail a process-owning transport adopts as
16
+ * its DEFAULT `flushed`, so a close routed around this class — e.g. the
17
+ * public `child.close()` — gets the same step 0 (PR #22819 round 4).
18
+ */
19
+ export const submissionTail = Symbol("Connection.submissionTail");
20
+ /**
21
+ * Module-private friend seam, like {@link submissionTail}: a request whose
22
+ * promise also yields the exact response frame it settled on, so a caller
23
+ * that refuses a successful result (the handshake's schema-fingerprint
24
+ * check) can put the whole refused frame on `ProtocolError.line`, the same
25
+ * value every other refusal carries (#50588 review).
26
+ */
27
+ export const requestWithFrame = Symbol("Connection.requestWithFrame");
28
+ /** The one typed error family consumers branch on (INV-012). */
29
+ export class MspError extends Error {
30
+ code;
31
+ kind;
32
+ data;
33
+ retryable;
34
+ constructor(error) {
35
+ super(error.message);
36
+ this.name = "MspError";
37
+ this.code = error.code;
38
+ this.kind = error.data?.kind ?? "unknown";
39
+ this.data = error.data === undefined ? {} : { ...error.data };
40
+ this.retryable = error.data?.retryable;
41
+ }
42
+ }
43
+ /** A local framing/correlation violation, never a server-authored MSP error. */
44
+ export class ProtocolError extends Error {
45
+ /**
46
+ * The inbound frame (wire line) the violation was detected on, when there
47
+ * is one — the whole line, e.g. the response a pending request refused;
48
+ * an oversized frame is cut to the frame limit. Absent for local state
49
+ * errors (closed connection, EOF) that no frame caused.
50
+ */
51
+ line;
52
+ constructor(message, line) {
53
+ super(message);
54
+ this.name = "ProtocolError";
55
+ this.line = line;
56
+ }
57
+ }
58
+ function requestKey(id) {
59
+ return `${typeof id === "number" ? "number" : "string"}:${String(id)}`;
60
+ }
61
+ function utf8ByteLength(text) {
62
+ let bytes = 0;
63
+ for (const scalar of text) {
64
+ const point = scalar.codePointAt(0);
65
+ bytes += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
66
+ }
67
+ return bytes;
68
+ }
69
+ /** FM-007: error evidence is bounded in UTF-8 BYTES, never code units. */
70
+ function truncateToUtf8Bytes(text, limitBytes) {
71
+ let bytes = 0;
72
+ let end = 0;
73
+ for (const scalar of text) {
74
+ const point = scalar.codePointAt(0);
75
+ const width = point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
76
+ if (bytes + width > limitBytes)
77
+ break;
78
+ bytes += width;
79
+ end += scalar.length;
80
+ }
81
+ return text.slice(0, end);
82
+ }
83
+ function canonical(value) {
84
+ if (Array.isArray(value))
85
+ return `[${value.map(canonical).join(",")}]`;
86
+ if (value !== null && typeof value === "object") {
87
+ const object = value;
88
+ return `{${Object.keys(object)
89
+ .sort()
90
+ .map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`)
91
+ .join(",")}}`;
92
+ }
93
+ return JSON.stringify(value);
94
+ }
95
+ // FR-45137-1: the dedup guards compare fingerprints, so a remembered command
96
+ // costs 64 hex chars no matter how large its payload (or ack) was. The hash
97
+ // guards a correctness check, so it is SHA-256, not a faster non-crypto hash
98
+ // whose collision would silently accept a different payload.
99
+ function fingerprint(canonicalForm) {
100
+ return createHash("sha256").update(canonicalForm, "utf8").digest("hex");
101
+ }
102
+ /** Create the SDK-owned UUIDv7 command-id source for a connection/client. */
103
+ export function createUuidV7Mint() {
104
+ let lastMs = -1;
105
+ let sequence = 0;
106
+ return () => {
107
+ let now = Date.now();
108
+ if (now <= lastMs) {
109
+ now = lastMs;
110
+ sequence += 1;
111
+ if (sequence > 0x0fff) {
112
+ now += 1;
113
+ sequence = 0;
114
+ }
115
+ }
116
+ else {
117
+ sequence = 0;
118
+ }
119
+ lastMs = now;
120
+ const bytes = new Uint8Array(16);
121
+ globalThis.crypto.getRandomValues(bytes);
122
+ let timestamp = now;
123
+ for (let index = 5; index >= 0; index -= 1) {
124
+ bytes[index] = timestamp & 0xff;
125
+ timestamp = Math.floor(timestamp / 256);
126
+ }
127
+ bytes[6] = 0x70 | ((sequence >> 8) & 0x0f);
128
+ bytes[7] = sequence & 0xff;
129
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
130
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
131
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
132
+ };
133
+ }
134
+ async function defaultRetryDelay(attempt) {
135
+ const ceilingMs = Math.min(2_000, 50 * 2 ** Math.max(0, attempt - 1));
136
+ const delayMs = Math.floor(Math.random() * (ceilingMs + 1));
137
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
138
+ }
139
+ function isObject(value) {
140
+ return value !== null && typeof value === "object" && !Array.isArray(value);
141
+ }
142
+ /**
143
+ * One MSP connection over newline-delimited JSON.
144
+ *
145
+ * The connection sends your requests and commands, correlates their
146
+ * responses, enforces the frame-size limit, and hands inbound notifications
147
+ * and server requests to the handlers you register. It is transport-agnostic:
148
+ * pair it with any `DuplexTransport`. Most applications never build one
149
+ * directly — `MuseClient.spawn` or `spawnMspConnection` does it for you;
150
+ * reach for a bare `Connection` when you need the raw notification feed or
151
+ * your own transport.
152
+ */
153
+ export class Connection {
154
+ #transport;
155
+ #frameLimitBytes;
156
+ #mintCommandId;
157
+ #mintRequestId;
158
+ #pending = new Map();
159
+ #commands = new Map();
160
+ #nextRequestId = 1;
161
+ #serverRequestHandler;
162
+ #notificationHandler;
163
+ #protocolErrorHandler;
164
+ #writeTail = Promise.resolve();
165
+ // Settles when every accepted frame has been HANDED to the transport, as
166
+ // opposed to #writeTail, which settles when the peer has taken it.
167
+ #submitTail = Promise.resolve();
168
+ #writeFailure;
169
+ #buffer = "";
170
+ #bufferBytes = 0; // FR-012: incremental accounting — never a full-buffer rescan per chunk
171
+ // FR-012: the last UTF-16 unit of #buffer (-1 when empty), maintained from each
172
+ // fresh chunk so the surrogate-pair reconciliation below never READS #buffer on
173
+ // the hot no-newline path — reading it (charCodeAt/indexOf/regex) flattens the
174
+ // V8 rope and copies the whole buffer per chunk, the O(bytes^2) this FR bans.
175
+ #bufferTailCode = -1;
176
+ #droppingOversized = false;
177
+ #finished = false;
178
+ #closedPromise;
179
+ #resolveClosed;
180
+ constructor(transport, options) {
181
+ this.#transport = transport;
182
+ this.#frameLimitBytes = options?.frameLimitBytes ?? DEFAULT_FRAME_LIMIT_BYTES;
183
+ this.#mintCommandId = options?.mintCommandId ?? createUuidV7Mint();
184
+ this.#mintRequestId = options?.mintRequestId ?? (() => {
185
+ const id = this.#nextRequestId;
186
+ this.#nextRequestId += 1;
187
+ return id;
188
+ });
189
+ this.#closedPromise = new Promise((resolve) => {
190
+ this.#resolveClosed = resolve;
191
+ });
192
+ void this.#readLoop();
193
+ }
194
+ get closed() {
195
+ return this.#closedPromise;
196
+ }
197
+ /** See {@link submissionTail}. */
198
+ [submissionTail]() {
199
+ return this.#submitTail;
200
+ }
201
+ request(method, params) {
202
+ return this.#request(method, params).then((framed) => framed.result);
203
+ }
204
+ /** See {@link requestWithFrame}. */
205
+ [requestWithFrame](method, params) {
206
+ return this.#request(method, params);
207
+ }
208
+ #request(method, params) {
209
+ if (this.#finished)
210
+ return Promise.reject(new ProtocolError("connection is closed"));
211
+ const id = this.#mintRequestId();
212
+ if (typeof id !== "string" && (typeof id !== "number" || !Number.isInteger(id))) {
213
+ return Promise.reject(new ProtocolError("request id must be a string or integer"));
214
+ }
215
+ if (this.#pending.has(requestKey(id))) {
216
+ return Promise.reject(new ProtocolError(`request id ${String(id)} is already in flight`));
217
+ }
218
+ const request = {
219
+ jsonrpc: "2.0",
220
+ id,
221
+ method,
222
+ ...(params === undefined ? {} : { params }),
223
+ };
224
+ return new Promise((resolve, reject) => {
225
+ this.#pending.set(requestKey(id), {
226
+ resolve: (result, line) => resolve({ result, line }),
227
+ reject,
228
+ });
229
+ void this.#write(request).catch((error) => {
230
+ this.#pending.delete(requestKey(id));
231
+ reject(error);
232
+ });
233
+ });
234
+ }
235
+ /**
236
+ * Take one id from THIS connection's single `commandId` minter (INV-013).
237
+ *
238
+ * Exposed for the callers that need the id BEFORE the ack: `sendUserTurn`
239
+ * records its optimistic SS4.13 entry under it, and an entry created only on
240
+ * the ack is invisible for exactly the window it exists to cover. The
241
+ * alternative was a facade-side mint, which is the second minter INV-013
242
+ * forbids — delegating here keeps the single-minter property intact and
243
+ * preserves the injected `mintCommandId` seam for deterministic transcripts.
244
+ *
245
+ * Pair it with `command(..., { commandId })`, which reuses the id instead of
246
+ * minting a second one.
247
+ */
248
+ mintCommandId() {
249
+ return this.#mintCommandId();
250
+ }
251
+ notify(method, params) {
252
+ const notification = {
253
+ jsonrpc: "2.0",
254
+ method,
255
+ ...(params === undefined ? {} : { params }),
256
+ };
257
+ void this.#write(notification).catch((error) => this.#finish(error));
258
+ }
259
+ onServerRequest(handler) {
260
+ this.#serverRequestHandler = handler;
261
+ }
262
+ onNotification(handler) {
263
+ this.#notificationHandler = handler;
264
+ }
265
+ onProtocolError(handler) {
266
+ this.#protocolErrorHandler = handler;
267
+ }
268
+ async flush() {
269
+ await this.#writeTail;
270
+ if (this.#writeFailure !== undefined)
271
+ throw this.#writeFailure;
272
+ }
273
+ async command(method, params, options) {
274
+ const commandId = options?.commandId ?? this.#mintCommandId();
275
+ const commandParams = { ...params, commandId };
276
+ const signature = fingerprint(canonical({ method, params: commandParams }));
277
+ const memory = this.#commands.get(commandId);
278
+ if (memory !== undefined && memory.signature !== signature) {
279
+ throw new ProtocolError(`commandId ${commandId} was reused with a different payload`);
280
+ }
281
+ const remembered = memory ?? { signature };
282
+ this.#commands.set(commandId, remembered);
283
+ const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
284
+ const retryDelay = options?.retryDelay ?? defaultRetryDelay;
285
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
286
+ try {
287
+ const ack = await this.request(method, commandParams);
288
+ // session/start's and session/resume's result schemas omit the
289
+ // redundant commandId echo (the two omitting families recorded in
290
+ // specs/14990-muse-sdk/contracts/sdk-surface.md; approval/decide
291
+ // DOES echo it). When a result carries it, preserve the strict echo
292
+ // check (TEST-018).
293
+ if (ack["commandId"] !== undefined && ack["commandId"] !== commandId) {
294
+ throw new ProtocolError(`${method} ack commandId ${String(ack["commandId"])} did not echo ${commandId}`);
295
+ }
296
+ const ackFingerprint = fingerprint(canonical(ack));
297
+ if (remembered.ack !== undefined && remembered.ack !== ackFingerprint) {
298
+ throw new ProtocolError(`${method} replay for commandId ${commandId} did not return a value-identical ack`);
299
+ }
300
+ remembered.ack = ackFingerprint;
301
+ return ack;
302
+ }
303
+ catch (error) {
304
+ const retryableNothingAdmitted = error instanceof MspError &&
305
+ ((error.code === -32001 && error.kind === "overloaded") ||
306
+ (error.code === -32031 && error.kind === "backpressured"));
307
+ if (!retryableNothingAdmitted || attempt === maxAttempts)
308
+ throw error;
309
+ await retryDelay(attempt, error);
310
+ }
311
+ }
312
+ throw new ProtocolError("unreachable command retry state");
313
+ }
314
+ async close() {
315
+ // COMPLETION must not gate teardown — that is the #15943 hang, since a
316
+ // peer that stopped reading never completes a write. SUBMISSION must,
317
+ // though: `#write` hands the frame to the transport on a later
318
+ // microtask, so ending the peer's input synchronously here dropped a
319
+ // just-accepted `notify()` and rejected close() with a raw
320
+ // ERR_STREAM_WRITE_AFTER_END on a HEALTHY peer (PR #22819 round 3).
321
+ // The submission promise goes TO the transport, which waits for it
322
+ // inside its own shutdown budget, so this class still knows nothing
323
+ // about processes, signals, or timeouts.
324
+ //
325
+ // The guarantee is SCOPED to transports that implement close() (PR
326
+ // #22819 round 4). A close-less transport has no budget to bound the
327
+ // wait with, and submission of frame N+1 only happens once frame N
328
+ // COMPLETES — exactly what a wedged peer withholds — so awaiting it here
329
+ // would hang teardown outright, where previously `#finish` at least
330
+ // rejected in-flight requests and settled `closed`. Finishing
331
+ // immediately is the honest behaviour for a transport that never told us
332
+ // how long it may take.
333
+ const tail = this.#writeTail;
334
+ if (this.#transport.close !== undefined) {
335
+ await this.#transport.close(this.#submitTail);
336
+ // Bounded by the transport's own shutdown: the peer is gone by now, so
337
+ // a pending write has errored and the tail records `#writeFailure`.
338
+ await tail;
339
+ }
340
+ else {
341
+ // Nothing here may wait on the peer: a stuck write's tail would never
342
+ // settle either. `#finish` has already rejected every in-flight
343
+ // request and settled `closed`; `flush()` still surfaces a later
344
+ // write failure.
345
+ this.#finish(new ProtocolError("connection closed"));
346
+ }
347
+ await this.#closedPromise;
348
+ }
349
+ async #write(frame) {
350
+ if (this.#finished)
351
+ throw new ProtocolError("connection is closed");
352
+ const line = `${JSON.stringify(frame)}\n`;
353
+ let markSubmitted;
354
+ const submitted = new Promise((resolve) => {
355
+ markSubmitted = resolve;
356
+ });
357
+ this.#submitTail = this.#submitTail.then(() => submitted);
358
+ const write = this.#writeTail.then(async () => {
359
+ try {
360
+ if (this.#finished)
361
+ throw new ProtocolError("connection is closed");
362
+ // Invoking write() IS submission: the transport's synchronous prefix
363
+ // has reached the underlying stream by the time the call returns.
364
+ const inFlight = this.#transport.write(line);
365
+ markSubmitted();
366
+ await inFlight;
367
+ }
368
+ catch (error) {
369
+ // A frame that can never be submitted must not wedge close() either.
370
+ markSubmitted();
371
+ throw error;
372
+ }
373
+ });
374
+ this.#writeTail = write.catch((error) => {
375
+ this.#writeFailure = error;
376
+ this.#finish(error);
377
+ });
378
+ await write;
379
+ }
380
+ async #readLoop() {
381
+ try {
382
+ for await (const chunk of this.#transport.incoming)
383
+ this.#ingest(chunk);
384
+ if (!this.#droppingOversized && this.#buffer.length > 0) {
385
+ this.#protocolError(new ProtocolError("inbound frame ended without a newline", this.#buffer));
386
+ }
387
+ this.#finish(new ProtocolError("connection reached EOF"));
388
+ }
389
+ catch (error) {
390
+ this.#finish(error);
391
+ }
392
+ }
393
+ #ingest(chunk) {
394
+ let remaining = chunk;
395
+ if (this.#droppingOversized) {
396
+ const newline = remaining.indexOf("\n");
397
+ if (newline < 0)
398
+ return;
399
+ this.#droppingOversized = false;
400
+ remaining = remaining.slice(newline + 1);
401
+ }
402
+ // FR-012: a chunk boundary can split a surrogate pair; the lone halves
403
+ // counted 3+3 UTF-8 bytes as they arrived, but the joined pair counts 4 at
404
+ // line-extract. Reconcile the +2 residue now, or legal frames accumulate
405
+ // drift until one false-trips the frame limit.
406
+ if (remaining.length === 0)
407
+ return;
408
+ const chunkHead = remaining.charCodeAt(0);
409
+ if (this.#bufferTailCode >= 0xd800 &&
410
+ this.#bufferTailCode <= 0xdbff &&
411
+ chunkHead >= 0xdc00 &&
412
+ chunkHead <= 0xdfff) {
413
+ this.#bufferBytes -= 2;
414
+ }
415
+ this.#buffer += remaining;
416
+ this.#bufferBytes += utf8ByteLength(remaining);
417
+ this.#bufferTailCode = remaining.charCodeAt(remaining.length - 1);
418
+ // FR-012: the retained buffer is newline-free on entry (the drain below runs
419
+ // to exhaustion), so only the fresh chunk can carry a newline. Gating the
420
+ // drain here keeps ingest O(bytes) instead of rescanning the whole buffer
421
+ // from offset 0 on every newline-less chunk (O(bytes^2)).
422
+ if (remaining.includes("\n")) {
423
+ while (true) {
424
+ const newline = this.#buffer.indexOf("\n");
425
+ if (newline < 0)
426
+ break;
427
+ const line = this.#buffer.slice(0, newline);
428
+ this.#buffer = this.#buffer.slice(newline + 1);
429
+ this.#bufferBytes -= utf8ByteLength(line) + 1;
430
+ this.#line(line);
431
+ }
432
+ // No post-drain tail refresh needed: the retained buffer's last unit IS
433
+ // the chunk's last unit (already recorded above) — or the chunk ended in
434
+ // "\n", which can never pass the high-surrogate check that is this
435
+ // field's only reader. Pinned by TEST-017's interleaved-chunk arm.
436
+ }
437
+ if (this.#bufferBytes > this.#frameLimitBytes) {
438
+ this.#protocolError(new ProtocolError(`inbound frame exceeds ${this.#frameLimitBytes} bytes`, truncateToUtf8Bytes(this.#buffer, this.#frameLimitBytes)));
439
+ this.#buffer = "";
440
+ this.#bufferBytes = 0;
441
+ this.#bufferTailCode = -1;
442
+ this.#droppingOversized = true;
443
+ }
444
+ }
445
+ #line(raw) {
446
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
447
+ if (line.trim().length === 0)
448
+ return;
449
+ if (utf8ByteLength(line) > this.#frameLimitBytes) {
450
+ this.#protocolError(new ProtocolError(`inbound frame exceeds ${this.#frameLimitBytes} bytes`, truncateToUtf8Bytes(line, this.#frameLimitBytes)));
451
+ return;
452
+ }
453
+ let parsed;
454
+ try {
455
+ parsed = JSON.parse(line);
456
+ }
457
+ catch {
458
+ this.#protocolError(new ProtocolError("inbound frame is not valid JSON", line));
459
+ return;
460
+ }
461
+ if (!isObject(parsed) || parsed["jsonrpc"] !== "2.0") {
462
+ this.#protocolError(new ProtocolError("inbound frame is not a JSON-RPC 2.0 object", line));
463
+ return;
464
+ }
465
+ const method = parsed["method"];
466
+ const id = parsed["id"];
467
+ if (typeof method === "string") {
468
+ if (typeof id === "string" || typeof id === "number") {
469
+ if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) {
470
+ this.#protocolError(new ProtocolError("server request id must be a positive integer", line));
471
+ return;
472
+ }
473
+ void this.#serverRequest(parsed).catch((error) => {
474
+ this.#finish(error);
475
+ });
476
+ return;
477
+ }
478
+ this.#notificationHandler?.(parsed);
479
+ return;
480
+ }
481
+ if (typeof id !== "string" && typeof id !== "number") {
482
+ this.#protocolError(new ProtocolError("response has no usable id", line));
483
+ return;
484
+ }
485
+ this.#response(parsed, id, line);
486
+ }
487
+ #response(frame, id, line) {
488
+ const pending = this.#pending.get(requestKey(id));
489
+ if (pending === undefined) {
490
+ this.#protocolError(new ProtocolError(`response for unknown request id ${String(id)}`, line));
491
+ return;
492
+ }
493
+ const hasResult = Object.hasOwn(frame, "result");
494
+ const hasError = Object.hasOwn(frame, "error");
495
+ if (hasResult === hasError) {
496
+ // FR-014: the MATCHED caller must settle — with no local timeout
497
+ // (INV-006) an unrejected caller would hang until EOF.
498
+ const violation = new ProtocolError("response must carry exactly one of result or error", line);
499
+ this.#pending.delete(requestKey(id));
500
+ pending.reject(violation);
501
+ this.#protocolError(violation);
502
+ return;
503
+ }
504
+ this.#pending.delete(requestKey(id));
505
+ if (hasResult) {
506
+ const result = frame["result"];
507
+ if (!isObject(result)) {
508
+ pending.reject(new ProtocolError("response result must be an object", line));
509
+ return;
510
+ }
511
+ pending.resolve(result, line);
512
+ return;
513
+ }
514
+ const error = frame["error"];
515
+ if (!isObject(error) ||
516
+ typeof error["code"] !== "number" ||
517
+ !Number.isInteger(error["code"]) ||
518
+ typeof error["message"] !== "string") {
519
+ pending.reject(new ProtocolError("error response has an invalid error object", line));
520
+ return;
521
+ }
522
+ const data = error["data"];
523
+ if (!isObject(data) || typeof data["kind"] !== "string") {
524
+ pending.reject(new ProtocolError("MSP error response has no data.kind", line));
525
+ return;
526
+ }
527
+ pending.reject(new MspError({
528
+ code: error["code"],
529
+ message: error["message"],
530
+ data: data,
531
+ }));
532
+ }
533
+ async #serverRequest(request) {
534
+ const id = request.id;
535
+ try {
536
+ if (this.#serverRequestHandler === undefined) {
537
+ throw new MspError({
538
+ code: -32601,
539
+ message: `method not found: ${request.method}`,
540
+ data: { kind: "methodNotFound", retryable: false },
541
+ });
542
+ }
543
+ const result = await this.#serverRequestHandler(request);
544
+ // FM-009: a failed reply write finishes the connection; it must not
545
+ // escape this fire-and-forget task as an unhandled rejection.
546
+ await this.#write({ jsonrpc: "2.0", id, result }).catch((writeError) => this.#finish(writeError));
547
+ }
548
+ catch (error) {
549
+ const typed = error instanceof MspError
550
+ ? error
551
+ : new MspError({
552
+ code: -32603,
553
+ message: error instanceof Error ? error.message : "server request handler failed",
554
+ data: { kind: "internal" },
555
+ });
556
+ await this.#write({
557
+ jsonrpc: "2.0",
558
+ id,
559
+ error: {
560
+ code: typed.code,
561
+ message: typed.message,
562
+ data: typed.data,
563
+ },
564
+ }).catch((writeError) => this.#finish(writeError)); // FM-009
565
+ }
566
+ }
567
+ #protocolError(error) {
568
+ this.#protocolErrorHandler?.(error);
569
+ }
570
+ #finish(reason) {
571
+ if (this.#finished)
572
+ return;
573
+ this.#finished = true;
574
+ const error = reason instanceof Error ? reason : new ProtocolError(String(reason));
575
+ for (const pending of this.#pending.values())
576
+ pending.reject(error);
577
+ this.#pending.clear();
578
+ this.#resolveClosed?.();
579
+ this.#resolveClosed = undefined;
580
+ }
581
+ }