@hegemonart/get-design-done 1.26.0 → 1.27.0

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 (33) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +10 -8
  5. package/SKILL.md +3 -0
  6. package/agents/README.md +29 -0
  7. package/package.json +1 -1
  8. package/reference/peer-cli-capabilities.md +151 -0
  9. package/reference/peer-protocols.md +266 -0
  10. package/reference/registry.json +14 -0
  11. package/reference/runtime-models.md +3 -3
  12. package/scripts/lib/bandit-router.cjs +214 -7
  13. package/scripts/lib/budget-enforcer.cjs +69 -1
  14. package/scripts/lib/event-stream/index.ts +14 -1
  15. package/scripts/lib/event-stream/types.ts +125 -1
  16. package/scripts/lib/install/runtimes.cjs +58 -0
  17. package/scripts/lib/peer-cli/acp-client.cjs +375 -0
  18. package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
  19. package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
  20. package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
  21. package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
  22. package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
  23. package/scripts/lib/peer-cli/asp-client.cjs +587 -0
  24. package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
  25. package/scripts/lib/peer-cli/registry.cjs +434 -0
  26. package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
  27. package/scripts/lib/runtime-detect.cjs +1 -1
  28. package/scripts/lib/session-runner/index.ts +215 -0
  29. package/scripts/lib/session-runner/types.ts +60 -0
  30. package/scripts/validate-frontmatter.ts +159 -1
  31. package/skills/peer-cli-add/SKILL.md +170 -0
  32. package/skills/peer-cli-customize/SKILL.md +110 -0
  33. package/skills/peers/SKILL.md +101 -0
@@ -0,0 +1,587 @@
1
+ // scripts/lib/peer-cli/asp-client.cjs
2
+ //
3
+ // Plan 27-02 — Codex App Server Protocol (ASP) client.
4
+ //
5
+ // Protocol shape adapted from greenpolo/cc-multi-cli `asp-client.mjs`
6
+ // (Apache 2.0). See NOTICE for full attribution.
7
+ //
8
+ // What this is:
9
+ // ASP is the wire protocol the Codex CLI exposes when invoked as
10
+ // `codex app-server`. Unlike ACP (which the four other peers speak —
11
+ // Gemini / Cursor / Copilot / Qwen — and treats each call as a one-shot
12
+ // prompt), ASP is THREAD-oriented: a long-lived conversation context
13
+ // that holds across many turns. Codex's tool calls, tool results, and
14
+ // reasoning traces all attach to a `threadId`.
15
+ //
16
+ // Wire format:
17
+ // * Outbound (client → Codex): one JSON object per line, terminated by
18
+ // `\n`, written to subprocess stdin. Same line-framing as ACP.
19
+ // * Inbound (Codex → client): one JSON object per line on subprocess
20
+ // stdout. Lines may be:
21
+ // - Method-call results: { id, result } ← correlated by `id`
22
+ // - Method-call errors: { id, error } ← correlated by `id`
23
+ // - Notifications: { method, params } ← no `id`, streaming
24
+ //
25
+ // Method surface this client exposes:
26
+ // * `threadStart(params)` → resolves to { threadId, ... }
27
+ // * `threadResume(threadId, params)` → resolves to thread state
28
+ // * `turn(threadId, text, opts)` → resolves to {status, content, usage}
29
+ // | {status: "error", error}
30
+ //
31
+ // Critical contract: turn() does NOT throw on Codex-reported errors.
32
+ // A `{status: "error"}` payload is a normal resolution path so the
33
+ // caller (registry / adapter / session-runner) can decide retry vs.
34
+ // fallback policy. turn() rejects only on transport-level breakage
35
+ // (process died mid-turn, line buffer overflow, malformed JSON the
36
+ // stream parser cannot recover from, an explicit `close()` call).
37
+ //
38
+ // Implementation details worth their salt:
39
+ //
40
+ // 1. Hand-rolled line-buffering. Node's built-in `readline` would work
41
+ // but adds an event-loop hop per line and copies the buffer; the
42
+ // hot path here is "10s of notifications per turn × N turns per
43
+ // cycle", so we keep it cheap by carrying a `tail` string forward
44
+ // across `data` events. Same approach the Phase 22 event-stream
45
+ // writer uses for its file-tail reader.
46
+ //
47
+ // 2. 16 MiB overflow cap on a single un-newline-terminated line. If a
48
+ // Codex tool dump (e.g. a giant file read) exceeds this, the client
49
+ // rejects all in-flight requests, kills the subprocess, and goes
50
+ // into a closed state. Without the cap a malformed `\n`-stripped
51
+ // line would grow the buffer until OOM. Cap chosen for parity with
52
+ // the ACP client (plan 27-01).
53
+ //
54
+ // 3. Per-turn notification fan-out. Each `turn()` registers a
55
+ // `(threadId, turnId)` mailbox. Notifications carrying that turnId
56
+ // are forwarded to the caller's `onNotification` hook (if any) and
57
+ // collected in `notifications[]` for the final result. The mailbox
58
+ // is torn down on completion regardless of status.
59
+ //
60
+ // 4. service_name is supplied by the caller on `threadStart`. The
61
+ // canonical value gdd uses is `"gdd_peer_delegation"` (per Plan
62
+ // 27-02 contract); the registry layer (Plan 27-05) sets it. We
63
+ // don't hard-code it here so unit tests can exercise multiple
64
+ // service names without monkey-patching.
65
+ //
66
+ // 5. experimentalRawEvents defaults to `false` per Plan 27-02 contract
67
+ // — gdd consumes structured turn output, not raw model tokens.
68
+ // Callers that want token-level streaming pass `true` explicitly.
69
+ //
70
+ // 6. threadResume API surface exists for v1.28+ cross-cycle context
71
+ // continuity. v1.27.0 always creates fresh threads via threadStart;
72
+ // the registry layer (27-05) doesn't call threadResume. We expose
73
+ // it now so future plans don't need an ASP-client breaking change.
74
+ //
75
+ // This module is `.cjs` (not `.ts`) per Phase 20-14 D-01 so it can be
76
+ // `require()`d from both the `.ts` runtime (session-runner, registry)
77
+ // and `.cjs` callers (broker-lifecycle in Plan 27-03) without needing
78
+ // `--experimental-strip-types` at every consumer site.
79
+
80
+ 'use strict';
81
+
82
+ const { spawn } = require('node:child_process');
83
+
84
+ /** Per-line cap before we treat the stream as malformed. */
85
+ const MAX_LINE_BYTES = 16 * 1024 * 1024;
86
+
87
+ /** Default options. Overridable per-call. */
88
+ const DEFAULTS = Object.freeze({
89
+ experimentalRawEvents: false,
90
+ });
91
+
92
+ /**
93
+ * Create an ASP client wrapping a Codex subprocess.
94
+ *
95
+ * @param {object} opts
96
+ * @param {string} opts.command Path or command name (e.g. 'codex').
97
+ * @param {string[]} [opts.args] Default `['app-server']`.
98
+ * @param {string} [opts.cwd] Working directory for the subprocess.
99
+ * @param {object} [opts.env] Environment override.
100
+ * @param {object} [opts.spawn] Pre-built ChildProcess (test injection).
101
+ * @returns {{
102
+ * threadStart: (params?: object) => Promise<{threadId: string, [k:string]: unknown}>,
103
+ * threadResume: (threadId: string, params?: object) => Promise<object>,
104
+ * turn: (threadId: string, text: string, opts?: {onNotification?: (n: object) => void, signal?: AbortSignal}) => Promise<object>,
105
+ * close: () => Promise<void>,
106
+ * readonly closed: boolean,
107
+ * }}
108
+ */
109
+ function createAspClient(opts) {
110
+ if (!opts || typeof opts !== 'object') {
111
+ throw new TypeError('createAspClient: opts is required');
112
+ }
113
+ if (typeof opts.command !== 'string' || opts.command.length === 0) {
114
+ throw new TypeError('createAspClient: opts.command must be a non-empty string');
115
+ }
116
+
117
+ const args = Array.isArray(opts.args) ? opts.args : ['app-server'];
118
+ const spawnOptions = {
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ };
121
+ if (typeof opts.cwd === 'string' && opts.cwd.length > 0) spawnOptions.cwd = opts.cwd;
122
+ if (opts.env && typeof opts.env === 'object') spawnOptions.env = opts.env;
123
+
124
+ // Test-injection seam: callers (or unit tests) can supply a pre-built
125
+ // ChildProcess so we don't actually fork a binary in tests. The mock
126
+ // server in tests/fixtures/peer-cli/mock-asp-server.cjs runs as a
127
+ // forked Node process and we wire it through this seam.
128
+ const child = (opts.spawn && typeof opts.spawn === 'object')
129
+ ? opts.spawn
130
+ : spawn(opts.command, args, spawnOptions);
131
+
132
+ // ── State ──────────────────────────────────────────────────────────────
133
+
134
+ /** Monotonic request-id counter. */
135
+ let nextId = 1;
136
+
137
+ /** id → { resolve, reject } for in-flight method calls. */
138
+ const pendingById = new Map();
139
+
140
+ /** turnId → { resolve, reject, onNotification, notifications[] } for in-flight turns. */
141
+ const turnsByTurnId = new Map();
142
+
143
+ /**
144
+ * Method-call requests that are awaiting a turn-start response —
145
+ * keyed by request id. Once the response carries a turnId we
146
+ * promote the entry into `turnsByTurnId`.
147
+ */
148
+ const turnsByRequestId = new Map();
149
+
150
+ /** Stdout line buffer (carried forward across `data` events). */
151
+ let stdoutBuf = '';
152
+
153
+ /** True once close() ran or the process died. */
154
+ let closed = false;
155
+
156
+ /** Last fatal error — used to reject newly-arriving requests. */
157
+ let fatalError = null;
158
+
159
+ // ── Stream wiring ──────────────────────────────────────────────────────
160
+
161
+ if (!child || !child.stdin || !child.stdout) {
162
+ throw new Error('createAspClient: spawn() did not yield stdin/stdout streams');
163
+ }
164
+
165
+ child.stdout.setEncoding('utf8');
166
+ child.stdout.on('data', onStdoutData);
167
+ child.stdout.on('end', () => onTransportClose(null));
168
+
169
+ // We don't act on stderr — the broker-lifecycle (Plan 27-03) and
170
+ // adapter layer (Plan 27-04) own logging policy. We DO consume it so
171
+ // the buffer doesn't fill and back-pressure the subprocess.
172
+ if (child.stderr && typeof child.stderr.resume === 'function') {
173
+ child.stderr.resume();
174
+ }
175
+
176
+ child.on('error', (err) => onTransportClose(err));
177
+ child.on('exit', (code, signal) => {
178
+ const reason = (code === 0)
179
+ ? null
180
+ : new Error(`asp-client: subprocess exited (code=${code}, signal=${signal ?? 'null'})`);
181
+ onTransportClose(reason);
182
+ });
183
+
184
+ // Don't let an EPIPE on stdin crash the host process — the subprocess
185
+ // may close stdin first when shutting down, and we surface that as
186
+ // fatalError via the exit handler above.
187
+ child.stdin.on('error', () => { /* swallowed; exit handler is canonical */ });
188
+
189
+ // ── Public API ─────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Start a new conversation thread. Resolves to the server's response
193
+ * which MUST contain `threadId`.
194
+ */
195
+ function threadStart(params) {
196
+ const merged = {
197
+ experimentalRawEvents: DEFAULTS.experimentalRawEvents,
198
+ ...(params && typeof params === 'object' ? params : {}),
199
+ };
200
+ return sendRequest('threadStart', merged);
201
+ }
202
+
203
+ /**
204
+ * Resume an existing thread. Resolves to the server's response.
205
+ */
206
+ function threadResume(threadId, params) {
207
+ if (typeof threadId !== 'string' || threadId.length === 0) {
208
+ return Promise.reject(new TypeError('threadResume: threadId must be a non-empty string'));
209
+ }
210
+ const merged = {
211
+ threadId,
212
+ ...(params && typeof params === 'object' ? params : {}),
213
+ };
214
+ return sendRequest('threadResume', merged);
215
+ }
216
+
217
+ /**
218
+ * Send one turn on a thread. Resolves to one of:
219
+ * { status: 'complete', content, usage, threadId, turnId, notifications }
220
+ * { status: 'error', error, threadId, turnId, notifications }
221
+ *
222
+ * Rejects only on transport breakage (subprocess died, client closed,
223
+ * line buffer overflow, AbortSignal triggered).
224
+ */
225
+ function turn(threadId, text, callOpts) {
226
+ if (typeof threadId !== 'string' || threadId.length === 0) {
227
+ return Promise.reject(new TypeError('turn: threadId must be a non-empty string'));
228
+ }
229
+ if (typeof text !== 'string') {
230
+ return Promise.reject(new TypeError('turn: text must be a string'));
231
+ }
232
+
233
+ const onNotification = (callOpts && typeof callOpts.onNotification === 'function')
234
+ ? callOpts.onNotification
235
+ : null;
236
+ const signal = (callOpts && callOpts.signal && typeof callOpts.signal.addEventListener === 'function')
237
+ ? callOpts.signal
238
+ : null;
239
+
240
+ if (closed) {
241
+ return Promise.reject(fatalError || new Error('asp-client: client is closed'));
242
+ }
243
+
244
+ return new Promise((resolve, reject) => {
245
+ const id = nextId++;
246
+ const entry = {
247
+ // Resolved either by a turn-completion notification (carrying
248
+ // status + content/error) or by the method-call response if the
249
+ // server chose to return the turn result inline.
250
+ resolve,
251
+ reject,
252
+ onNotification,
253
+ notifications: [],
254
+ threadId,
255
+ turnId: null, // populated by the method-response carrying turnId
256
+ settled: false,
257
+ signal,
258
+ onAbort: null,
259
+ };
260
+ turnsByRequestId.set(id, entry);
261
+
262
+ if (signal) {
263
+ if (signal.aborted) {
264
+ finalizeTurn(entry, /*reject*/ true, new Error('asp-client: turn aborted'));
265
+ return;
266
+ }
267
+ entry.onAbort = () => {
268
+ finalizeTurn(entry, /*reject*/ true, new Error('asp-client: turn aborted'));
269
+ };
270
+ signal.addEventListener('abort', entry.onAbort, { once: true });
271
+ }
272
+
273
+ const wireOk = writeJson({
274
+ jsonrpc: '2.0',
275
+ id,
276
+ method: 'turn',
277
+ params: { threadId, text },
278
+ });
279
+ if (!wireOk) {
280
+ finalizeTurn(entry, /*reject*/ true, fatalError || new Error('asp-client: stdin write failed'));
281
+ }
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Tear down the subprocess and reject all in-flight requests.
287
+ */
288
+ function close() {
289
+ if (closed) return Promise.resolve();
290
+ onTransportClose(null);
291
+ return new Promise((resolve) => {
292
+ // Give the subprocess a moment to flush; force-kill on the
293
+ // second tick if it's still alive. This mirrors the broker
294
+ // lifecycle's shutdown contract (Plan 27-03).
295
+ try { child.stdin.end(); } catch { /* already closed */ }
296
+ const t = setTimeout(() => {
297
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
298
+ }, 50);
299
+ // If the child was already gone, exit fired before close() and
300
+ // we resolve immediately on next tick.
301
+ child.once('exit', () => {
302
+ clearTimeout(t);
303
+ resolve();
304
+ });
305
+ // Belt-and-braces: if 'exit' already fired before this listener
306
+ // attaches, the callback above never runs. Resolve after a hard
307
+ // cap so close() never deadlocks.
308
+ setTimeout(resolve, 200).unref?.();
309
+ });
310
+ }
311
+
312
+ // ── Internals ──────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Send a request and return a promise that resolves with the result
316
+ * (or rejects on error / transport failure).
317
+ */
318
+ function sendRequest(method, params) {
319
+ if (closed) {
320
+ return Promise.reject(fatalError || new Error('asp-client: client is closed'));
321
+ }
322
+ const id = nextId++;
323
+ return new Promise((resolve, reject) => {
324
+ pendingById.set(id, { resolve, reject });
325
+ const ok = writeJson({ jsonrpc: '2.0', id, method, params });
326
+ if (!ok) {
327
+ pendingById.delete(id);
328
+ reject(fatalError || new Error('asp-client: stdin write failed'));
329
+ }
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Serialize and write a single newline-terminated JSON line.
335
+ * Returns false on transport failure (caller is responsible for
336
+ * cleanup of any pending entry).
337
+ */
338
+ function writeJson(obj) {
339
+ if (closed) return false;
340
+ let line;
341
+ try {
342
+ line = JSON.stringify(obj) + '\n';
343
+ } catch (err) {
344
+ // Caller passed something non-serializable; surface as fatal so
345
+ // we don't silently drop a request. Synchronous failure path —
346
+ // we want the writer (sendRequest / turn) to reject promptly.
347
+ onTransportClose(err);
348
+ return false;
349
+ }
350
+ try {
351
+ child.stdin.write(line);
352
+ return true;
353
+ } catch (err) {
354
+ onTransportClose(err);
355
+ return false;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Stdout chunk handler. Splits on `\n`, parses each complete line
361
+ * as JSON, dispatches by id (response) or method (notification).
362
+ * Carries an unfinished tail forward.
363
+ */
364
+ function onStdoutData(chunk) {
365
+ if (closed) return;
366
+ stdoutBuf += chunk;
367
+ if (stdoutBuf.length > MAX_LINE_BYTES && !stdoutBuf.includes('\n')) {
368
+ onTransportClose(new Error(
369
+ `asp-client: line buffer exceeded ${MAX_LINE_BYTES} bytes without newline`,
370
+ ));
371
+ return;
372
+ }
373
+
374
+ let nlIdx;
375
+ // eslint-disable-next-line no-cond-assign
376
+ while ((nlIdx = stdoutBuf.indexOf('\n')) !== -1) {
377
+ const line = stdoutBuf.slice(0, nlIdx);
378
+ stdoutBuf = stdoutBuf.slice(nlIdx + 1);
379
+ if (line.trim().length === 0) continue;
380
+ let msg;
381
+ try {
382
+ msg = JSON.parse(line);
383
+ } catch (err) {
384
+ // Single malformed line — log via fatal close. We don't try to
385
+ // skip-and-continue because once framing alignment is lost we
386
+ // can't trust subsequent lines either.
387
+ onTransportClose(new Error(`asp-client: malformed JSON line: ${err.message}`));
388
+ return;
389
+ }
390
+ dispatch(msg);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Route one parsed message: method-result, method-error, or
396
+ * notification.
397
+ */
398
+ function dispatch(msg) {
399
+ if (msg === null || typeof msg !== 'object') return;
400
+
401
+ // Notification (no `id`): carries a `method` and `params`.
402
+ if (typeof msg.method === 'string' && !('id' in msg)) {
403
+ const params = (msg.params && typeof msg.params === 'object') ? msg.params : {};
404
+ const turnId = (typeof params.turnId === 'string') ? params.turnId : null;
405
+ if (turnId !== null) {
406
+ const entry = turnsByTurnId.get(turnId);
407
+ if (entry) {
408
+ // Always record for the final result payload.
409
+ entry.notifications.push(msg);
410
+ if (entry.onNotification) {
411
+ try { entry.onNotification(msg); } catch { /* user hook errors don't kill the stream */ }
412
+ }
413
+ // Terminal notifications close the turn. Codex emits
414
+ // `turn.complete` (or `turn.error`) as the final notification
415
+ // in the stream; the method-response may also carry the
416
+ // result. We accept either path — whichever lands first wins.
417
+ if (msg.method === 'turn.complete' || msg.method === 'turnComplete') {
418
+ const result = {
419
+ status: 'complete',
420
+ content: params.content,
421
+ usage: params.usage,
422
+ threadId: entry.threadId,
423
+ turnId: entry.turnId,
424
+ notifications: entry.notifications,
425
+ };
426
+ finalizeTurn(entry, /*reject*/ false, result);
427
+ } else if (msg.method === 'turn.error' || msg.method === 'turnError') {
428
+ const result = {
429
+ status: 'error',
430
+ error: params.error || { code: 'unknown', message: 'turn errored without detail' },
431
+ threadId: entry.threadId,
432
+ turnId: entry.turnId,
433
+ notifications: entry.notifications,
434
+ };
435
+ // NOTE: resolves (does not reject) — Codex-reported errors
436
+ // are a normal control path.
437
+ finalizeTurn(entry, /*reject*/ false, result);
438
+ }
439
+ }
440
+ }
441
+ return;
442
+ }
443
+
444
+ // Method response (has `id`): result or error.
445
+ if (typeof msg.id === 'number' || typeof msg.id === 'string') {
446
+ const id = msg.id;
447
+
448
+ // Was this a turn() request? If so, the response carries turnId
449
+ // (and possibly the inline result if the server didn't stream).
450
+ const turnEntry = turnsByRequestId.get(id);
451
+ if (turnEntry) {
452
+ turnsByRequestId.delete(id);
453
+ if (msg.error) {
454
+ // Method-call-level error (e.g. invalid params). Distinct
455
+ // from a `{status: "error"}` turn result — this rejects.
456
+ finalizeTurn(turnEntry, /*reject*/ true, asError(msg.error));
457
+ return;
458
+ }
459
+ const result = (msg.result && typeof msg.result === 'object') ? msg.result : {};
460
+ const turnId = typeof result.turnId === 'string' ? result.turnId : null;
461
+ if (turnId === null) {
462
+ finalizeTurn(turnEntry, /*reject*/ true,
463
+ new Error('asp-client: turn response missing turnId'));
464
+ return;
465
+ }
466
+ turnEntry.turnId = turnId;
467
+ // If the server inlined the final status in the method response,
468
+ // settle immediately. Otherwise wait for streaming notifications.
469
+ if (typeof result.status === 'string') {
470
+ if (result.status === 'complete') {
471
+ finalizeTurn(turnEntry, /*reject*/ false, {
472
+ status: 'complete',
473
+ content: result.content,
474
+ usage: result.usage,
475
+ threadId: turnEntry.threadId,
476
+ turnId,
477
+ notifications: turnEntry.notifications,
478
+ });
479
+ } else if (result.status === 'error') {
480
+ finalizeTurn(turnEntry, /*reject*/ false, {
481
+ status: 'error',
482
+ error: result.error || { code: 'unknown', message: 'turn errored without detail' },
483
+ threadId: turnEntry.threadId,
484
+ turnId,
485
+ notifications: turnEntry.notifications,
486
+ });
487
+ } else {
488
+ // Unknown status — register the entry so streaming
489
+ // notifications can settle it.
490
+ turnsByTurnId.set(turnId, turnEntry);
491
+ }
492
+ } else {
493
+ // No inline status: register for streaming completion.
494
+ turnsByTurnId.set(turnId, turnEntry);
495
+ }
496
+ return;
497
+ }
498
+
499
+ // Plain method response (threadStart / threadResume).
500
+ const pending = pendingById.get(id);
501
+ if (!pending) return; // unsolicited response — drop
502
+ pendingById.delete(id);
503
+ if (msg.error) {
504
+ pending.reject(asError(msg.error));
505
+ } else {
506
+ pending.resolve(msg.result);
507
+ }
508
+ return;
509
+ }
510
+
511
+ // Anything else (no method, no id) is silently ignored — Codex may
512
+ // emit progress envelopes we don't recognize, and we don't want to
513
+ // tear down on forward-compat noise.
514
+ }
515
+
516
+ /**
517
+ * Resolve or reject the turn entry, removing all bookkeeping.
518
+ * `value` is either the result payload (when resolving) or an Error
519
+ * (when rejecting).
520
+ */
521
+ function finalizeTurn(entry, isReject, value) {
522
+ if (entry.settled) return;
523
+ entry.settled = true;
524
+ if (entry.signal && entry.onAbort) {
525
+ try { entry.signal.removeEventListener('abort', entry.onAbort); } catch { /* noop */ }
526
+ }
527
+ if (entry.turnId !== null) turnsByTurnId.delete(entry.turnId);
528
+ // Remove the request-id mapping if it survived this far.
529
+ for (const [reqId, e] of turnsByRequestId.entries()) {
530
+ if (e === entry) { turnsByRequestId.delete(reqId); break; }
531
+ }
532
+ if (isReject) entry.reject(value);
533
+ else entry.resolve(value);
534
+ }
535
+
536
+ /**
537
+ * Convert an ASP `error` envelope into an Error with the original
538
+ * code/data attached for the caller's classifier.
539
+ */
540
+ function asError(envelope) {
541
+ const code = (envelope && (typeof envelope.code === 'string' || typeof envelope.code === 'number'))
542
+ ? envelope.code : 'unknown';
543
+ const message = (envelope && typeof envelope.message === 'string')
544
+ ? envelope.message : 'asp-client: server returned error';
545
+ const e = new Error(`asp-client: ${message} (code=${code})`);
546
+ e.code = code;
547
+ e.data = envelope && envelope.data;
548
+ return e;
549
+ }
550
+
551
+ /**
552
+ * Mark the transport as closed and reject every in-flight call.
553
+ * `cause` may be null for a clean shutdown.
554
+ */
555
+ function onTransportClose(cause) {
556
+ if (closed) return;
557
+ closed = true;
558
+ fatalError = cause;
559
+
560
+ const err = cause || new Error('asp-client: transport closed');
561
+
562
+ for (const [, p] of pendingById) {
563
+ try { p.reject(err); } catch { /* noop */ }
564
+ }
565
+ pendingById.clear();
566
+
567
+ for (const [, entry] of turnsByRequestId) {
568
+ try { finalizeTurn(entry, /*reject*/ true, err); } catch { /* noop */ }
569
+ }
570
+ turnsByRequestId.clear();
571
+
572
+ for (const [, entry] of turnsByTurnId) {
573
+ try { finalizeTurn(entry, /*reject*/ true, err); } catch { /* noop */ }
574
+ }
575
+ turnsByTurnId.clear();
576
+ }
577
+
578
+ return {
579
+ threadStart,
580
+ threadResume,
581
+ turn,
582
+ close,
583
+ get closed() { return closed; },
584
+ };
585
+ }
586
+
587
+ module.exports = { createAspClient, MAX_LINE_BYTES };