@actana/sdk 0.2.2

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/core-client.d.ts +616 -0
  4. package/dist/core-client.d.ts.map +1 -0
  5. package/dist/core-client.js +1036 -0
  6. package/dist/core-client.js.map +1 -0
  7. package/dist/core-link-cursor-storage.d.ts +32 -0
  8. package/dist/core-link-cursor-storage.d.ts.map +1 -0
  9. package/dist/core-link-cursor-storage.js +66 -0
  10. package/dist/core-link-cursor-storage.js.map +1 -0
  11. package/dist/core-link-frames.d.ts +1123 -0
  12. package/dist/core-link-frames.d.ts.map +1 -0
  13. package/dist/core-link-frames.js +349 -0
  14. package/dist/core-link-frames.js.map +1 -0
  15. package/dist/core-link-socket.d.ts +54 -0
  16. package/dist/core-link-socket.d.ts.map +1 -0
  17. package/dist/core-link-socket.js +74 -0
  18. package/dist/core-link-socket.js.map +1 -0
  19. package/dist/core-link-transport.d.ts +177 -0
  20. package/dist/core-link-transport.d.ts.map +1 -0
  21. package/dist/core-link-transport.js +432 -0
  22. package/dist/core-link-transport.js.map +1 -0
  23. package/dist/core-registration-blob.d.ts +52 -0
  24. package/dist/core-registration-blob.d.ts.map +1 -0
  25. package/dist/core-registration-blob.js +61 -0
  26. package/dist/core-registration-blob.js.map +1 -0
  27. package/dist/core-session.d.ts +321 -0
  28. package/dist/core-session.d.ts.map +1 -0
  29. package/dist/core-session.js +660 -0
  30. package/dist/core-session.js.map +1 -0
  31. package/dist/durable-core-client.d.ts +172 -0
  32. package/dist/durable-core-client.d.ts.map +1 -0
  33. package/dist/durable-core-client.js +264 -0
  34. package/dist/durable-core-client.js.map +1 -0
  35. package/dist/terminal-screen.d.ts +139 -0
  36. package/dist/terminal-screen.d.ts.map +1 -0
  37. package/dist/terminal-screen.js +807 -0
  38. package/dist/terminal-screen.js.map +1 -0
  39. package/package.json +51 -0
@@ -0,0 +1,1036 @@
1
+ // `CoreClient` — the default entry point: connect, authenticate, ask, close.
2
+ //
3
+ // One socket, one Core, mTLS + bearer from a registration blob (#129 D1, D5).
4
+ // **There is no `Fleet` type here and there must not be**: a Core client speaks
5
+ // to the machine it dialed, and holding several is the Panel's job, not a shape
6
+ // this package grows. That is D5, and the Panel remains the fleet.
7
+ //
8
+ // This is what the CLI and a script use (#129 D6, "one-shot is the default"):
9
+ // connect, ask a question, act on the answer, exit. It does not reconnect, does
10
+ // not persist an event cursor, and does not ping — every one of those belongs to
11
+ // a program that stays up, and that program is
12
+ // {@link DurableCoreClient} in `durable-core-client.ts`, which is this class
13
+ // plus a supervisor. Both sit on one {@link CoreLinkTransport}, so the wire is
14
+ // written once.
15
+ //
16
+ // Extracted from the Panel's `PtyCoreLinkClient` (issue 153), which no longer
17
+ // exists: #156 deleted it and pointed the Panel here. The typed method set below
18
+ // is deliberately that class's, frame for frame, which is what made that
19
+ // migration a swap rather than a rewrite of its call sites; the parts
20
+ // that class did *not* have — an explicit `connect()` you can await, a
21
+ // connection that does not resurrect itself — are what a one-shot client needs
22
+ // and a Panel never asked for.
23
+ import { coreLinkProtocolCompatible, readMultiConnectionCapability, } from "./core-link-frames.js";
24
+ import { CoreLinkTransport, } from "./core-link-transport.js";
25
+ import { createNodeCoreLinkSocket, } from "./core-link-socket.js";
26
+ import { coreConnectionFromBlob } from "./core-registration-blob.js";
27
+ /**
28
+ * Request frames that only mean anything against a Core announcing
29
+ * `multiConnection` on `ready` (ADR 0024 D11) — the one list
30
+ * {@link CoreClient.canSendMultiConnectionFrames} guards.
31
+ *
32
+ * `ptySubscribe` / `ptyUnsubscribe` (ADR 0024 D2) are the first entries: a Core
33
+ * that fans a PTY out per subscription is exactly a multi-connection Core, and
34
+ * one that never announced the capability has no subscription list to put a PTY
35
+ * on. The Session lock's `claim` / `release` / `forceTakeover` (D3–D7) joined
36
+ * them for the same reason in its own key: a single-connection Core has no lock
37
+ * table to address, because it evicts every client but one and therefore has
38
+ * nothing for a lock to arbitrate between. `reclaim` (D9) is the sharpest of the
39
+ * three: a single-connection Core evicts its predecessor on connect *already*,
40
+ * so the frame asks for what that Core has just done, out of a lock table it
41
+ * does not have.
42
+ *
43
+ * Being in this set is not the whole story for a frame whose caller has
44
+ * something better to do than fail. A caller that can degrade consults
45
+ * {@link CoreClient.canSendMultiConnectionFrames} and takes the
46
+ * single-connection route itself — see {@link CoreClient.ptySubscribe} and
47
+ * {@link CoreClient.claim}, which both do exactly that. The rejection in
48
+ * {@link CoreClient.request} is the backstop for callers that ask without
49
+ * checking, not the designed path for the frames listed here.
50
+ */
51
+ export const MULTI_CONNECTION_ONLY_FRAME_TYPES = new Set([
52
+ "ptySubscribe",
53
+ "ptyUnsubscribe",
54
+ "claim",
55
+ "release",
56
+ "forceTakeover",
57
+ "reclaim",
58
+ ]);
59
+ /**
60
+ * The rejection a caller gets when the Core answers a request with an `error`
61
+ * frame — carrying that frame's machine-readable {@link CoreLinkErrorCode}
62
+ * alongside its prose (issue 144).
63
+ *
64
+ * `code` exists precisely so a caller does not have to match on `message`, and a
65
+ * client that threw a bare `Error` would put the parsing back that the field was
66
+ * added to remove. It is optional on the wire and optional here: absent on every
67
+ * error that shipped before the field, so a reader takes the code when it is
68
+ * there and falls back to the message when it is not — never the reverse.
69
+ *
70
+ * `session-locked` is its first value and the reason this class exists: a
71
+ * mutation refused because another Core client holds that Session throws,
72
+ * whereas a mutation aimed at a Session this Core no longer has resolves
73
+ * (`ok: false` / `task: null`). One of the two is worth retrying after a claim
74
+ * and the other never is, and telling them apart is the caller's whole job.
75
+ */
76
+ export class CoreLinkRequestError extends Error {
77
+ /** The `code` off the `error` frame, when it carried one. */
78
+ code;
79
+ constructor(message, code) {
80
+ super(message);
81
+ this.name = "CoreLinkRequestError";
82
+ this.code = code;
83
+ }
84
+ }
85
+ /** Thrown when a bearer is refused: `expired`, `bad-signature`, `malformed`. */
86
+ export class CoreLinkAuthError extends Error {
87
+ reason;
88
+ constructor(reason) {
89
+ super(`core-link authentication failed: ${reason}`);
90
+ this.name = "CoreLinkAuthError";
91
+ this.reason = reason;
92
+ }
93
+ }
94
+ /** Default deadline for a dial: `ready` plus, with a bearer, `authOk`. */
95
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
96
+ /** Default deadline for one request/response round trip. */
97
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
98
+ /**
99
+ * Mint this client's Core client id (ADR 0024 D9).
100
+ *
101
+ * Random rather than derived from anything: two clients must not collide, and
102
+ * every input that would make an id stable across processes — the endpoint, the
103
+ * coreId, the bearer — is shared by every client dialing that machine, which is
104
+ * the one shape D9 forbids. Unguessability buys nothing security-wise and is not
105
+ * claimed to; nothing verifies this string, and a Core hands out no more to a
106
+ * client presenting it than to one that asks for a `forceTakeover` outright.
107
+ *
108
+ * The `sdk-` prefix is for the Core's log, where a reclaim line is otherwise an
109
+ * opaque token with no clue which of a machine's clients reconnected.
110
+ */
111
+ function mintCoreClientId() {
112
+ const random = globalThis.crypto?.randomUUID?.();
113
+ if (random)
114
+ return `sdk-${random}`;
115
+ // Every runtime this supports has `crypto.randomUUID`. The fallback keeps an
116
+ // id — which only has to differ from its neighbours — from being the thing
117
+ // that throws in an exotic one.
118
+ return `sdk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
119
+ }
120
+ /**
121
+ * A Core client over one core link.
122
+ *
123
+ * Lifecycle: construct, `await connect()`, ask, `close()`. Construction opens
124
+ * nothing — a client you have not connected has sent no bytes, which is what
125
+ * makes `connect()`'s rejection the one place a dial failure is reported.
126
+ */
127
+ export class CoreClient {
128
+ /** The URL this client dials. Public because a log line without it is a riddle. */
129
+ url;
130
+ /**
131
+ * This client's Core client id — the same string on every connection it opens,
132
+ * which is the whole of what makes a reconnect reclaimable (ADR 0024 D9).
133
+ *
134
+ * Readonly: an id that moved between connections would reclaim nothing and
135
+ * leave the predecessor holding its Sessions for the full heartbeat timeout,
136
+ * which is the exact failure the frame exists to remove.
137
+ */
138
+ clientId;
139
+ createSocket;
140
+ bearer;
141
+ heartbeat;
142
+ connectTimeoutMs;
143
+ requestTimeoutMs;
144
+ multiConnectionOnlyFrameTypes;
145
+ transport = null;
146
+ closed = false;
147
+ /**
148
+ * True once a `subscribe` has gone out for this client — see
149
+ * {@link subscribeEvents}. Per client rather than per connection: it records
150
+ * that this client *wants* the event stream, which is what a reconnecting
151
+ * subclass re-asks for, and what stops a second caller asking twice.
152
+ */
153
+ eventsSubscribed = false;
154
+ /** True once this connection has been established — reset per connection. */
155
+ established = false;
156
+ /** Requests written to no socket yet. They survive a reconnect; a timeout is what ends them. */
157
+ queue = [];
158
+ /** Requests on the wire right now, so a dying socket can fail them at once. */
159
+ inFlight = new Set();
160
+ connectPromise = null;
161
+ connectSettle = null;
162
+ /** This connection's `ready` frame; null before it lands and after a drop. */
163
+ ready = null;
164
+ /**
165
+ * This Core's `multiConnection` capability as announced on the *current*
166
+ * connection; null when absent and before `ready` lands.
167
+ *
168
+ * Re-read on every `ready` rather than remembered across connections: a Core
169
+ * can be downgraded, and a stale `true` here would send frames the Core no
170
+ * longer understands. Null until the frame arrives, so the gate is closed
171
+ * during the window where the answer is genuinely unknown.
172
+ */
173
+ multiConnection = null;
174
+ authOkFrame = null;
175
+ readyListeners = new Set();
176
+ dataListeners = new Set();
177
+ exitListeners = new Set();
178
+ eventListeners = new Set();
179
+ eventsReplayedListeners = new Set();
180
+ authOkListeners = new Set();
181
+ authErrorListeners = new Set();
182
+ disconnectedListeners = new Set();
183
+ reclaimedListeners = new Set();
184
+ constructor(opts) {
185
+ const connection = opts.blob ? coreConnectionFromBlob(opts.blob) : null;
186
+ const url = connection?.url ?? opts.url;
187
+ if (!url) {
188
+ throw new Error("CoreClient needs a url or a registration blob to dial");
189
+ }
190
+ this.url = url;
191
+ const tls = connection ? connection.tls : (opts.tls ?? null);
192
+ this.bearer = connection ? connection.bearer : (opts.bearer ?? null);
193
+ this.createSocket =
194
+ opts.createSocket ?? ((dialUrl) => createNodeCoreLinkSocket(dialUrl, tls ?? undefined));
195
+ this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
196
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
197
+ this.heartbeat = opts.heartbeat ?? false;
198
+ this.clientId = opts.clientId ?? mintCoreClientId();
199
+ this.multiConnectionOnlyFrameTypes =
200
+ opts.multiConnectionOnlyFrameTypes ?? MULTI_CONNECTION_ONLY_FRAME_TYPES;
201
+ }
202
+ /** Build a client straight off a registration blob (#129 D1). */
203
+ static fromRegistrationBlob(blob, opts = {}) {
204
+ return new CoreClient({ ...opts, blob });
205
+ }
206
+ // ─── Connection lifecycle ──────────────────────────────────────────────────
207
+ /**
208
+ * Open the link and settle when the Core has said who it is: resolves after
209
+ * `ready` and, when a bearer is configured, after `authOk`.
210
+ *
211
+ * Idempotent **while it is in flight** — a second call returns the first
212
+ * call's promise, so two callers racing to connect share one socket rather
213
+ * than opening two. Once it settles the memo is dropped, and what a later
214
+ * call gets is the state of the link *now*: the current connection's info if
215
+ * one is up, and a fresh attempt if none is.
216
+ *
217
+ * That distinction is the whole point. A memo kept past its rejection is a
218
+ * client that reports itself permanently unconnectable — a durable client
219
+ * whose connect deadline expired once would hand every later caller that same
220
+ * rejection long after its supervisor had reconnected, because nothing here
221
+ * ever cleared it (issue 153 review, note 3; #156 builds on this).
222
+ *
223
+ * Rejects on a refused bearer, on a socket that closed before it was
224
+ * established, and on the connect deadline. A {@link DurableCoreClient} keeps
225
+ * retrying underneath regardless; what rejects there is this promise, not the
226
+ * client.
227
+ */
228
+ connect() {
229
+ if (this.closed)
230
+ return Promise.reject(new Error("core-link client closed"));
231
+ if (this.connectPromise)
232
+ return this.connectPromise;
233
+ // A link that is already up answers straight away rather than dialing a
234
+ // second one over a working first.
235
+ if (this.established)
236
+ return Promise.resolve(this.connectionInfo());
237
+ this.connectPromise = new Promise((resolve, reject) => {
238
+ const timer = setTimeout(() => {
239
+ this.failConnect(new Error(`core-link connect to ${this.url} timed out`));
240
+ }, this.connectTimeoutMs);
241
+ this.connectSettle = { resolve, reject, timer };
242
+ });
243
+ // Dial only when nothing is already dialing on this client's behalf. A
244
+ // transport mid-handshake, or a durable client's backoff timer about to make
245
+ // one, will settle the promise above through the ordinary handlers — opening
246
+ // another socket here would orphan whichever of the two lost the race.
247
+ if (!this.transport && !this.reconnectScheduled())
248
+ this.openTransport();
249
+ return this.connectPromise;
250
+ }
251
+ /**
252
+ * Is a reconnect already pending, so `connect()` must not dial itself? Never
253
+ * for a one-shot client, which has no supervisor; {@link DurableCoreClient}
254
+ * overrides it with its backoff timer.
255
+ */
256
+ reconnectScheduled() {
257
+ return false;
258
+ }
259
+ /**
260
+ * Open one connection and wire it to this client. The transport is per socket;
261
+ * every listener above is per client, which is what lets a durable subclass
262
+ * replace the former without anything above noticing.
263
+ */
264
+ openTransport() {
265
+ if (this.closed)
266
+ return;
267
+ this.ready = null;
268
+ this.multiConnection = null;
269
+ this.authOkFrame = null;
270
+ this.established = false;
271
+ this.transport = new CoreLinkTransport({
272
+ url: this.url,
273
+ createSocket: this.createSocket,
274
+ bearer: this.bearer,
275
+ heartbeat: this.heartbeat,
276
+ handlers: {
277
+ onReady: (frame) => {
278
+ this.ready = frame;
279
+ this.multiConnection = readMultiConnectionCapability(frame.multiConnection);
280
+ const info = this.connectionInfo();
281
+ for (const cb of this.readyListeners)
282
+ cb(info);
283
+ this.maybeEstablish();
284
+ },
285
+ onWritable: () => {
286
+ // Writability alone does not drain the queue. `maybeEstablish` flushes
287
+ // it once the connection is established, *behind* what that connection
288
+ // owed the Core — and on the no-bearer path writability lands before
289
+ // `ready`, so flushing here regardless would put a caller's request on
290
+ // the wire ahead of `reclaim` and `subscribe`, which is the ordering
291
+ // the comment on `onConnectionEstablished` promises callers. The guard
292
+ // covers the case where writability returns on a connection that is
293
+ // already established.
294
+ this.maybeEstablish();
295
+ if (this.established)
296
+ this.flushQueue();
297
+ },
298
+ onAuthOk: (frame) => {
299
+ this.authOkFrame = frame;
300
+ for (const cb of this.authOkListeners)
301
+ cb({ coreId: frame.coreId, exp: frame.exp });
302
+ },
303
+ onAuthError: (reason) => {
304
+ for (const cb of this.authErrorListeners)
305
+ cb({ reason });
306
+ this.failConnect(new CoreLinkAuthError(reason));
307
+ },
308
+ onData: (frame) => {
309
+ for (const cb of this.dataListeners)
310
+ cb(frame);
311
+ },
312
+ onExit: (frame) => {
313
+ for (const cb of this.exitListeners)
314
+ cb(frame);
315
+ },
316
+ onEvent: (event) => this.deliverEvent(event),
317
+ onEventsReplayed: (lastEventId) => this.deliverEventsReplayed(lastEventId),
318
+ onClose: (reason) => {
319
+ this.ready = null;
320
+ this.multiConnection = null;
321
+ this.authOkFrame = null;
322
+ // Cleared here and not only on the next dial: between a socket dying
323
+ // and a durable client's backoff opening the next one, this client is
324
+ // not established, and a `connect()` arriving in that gap must wait
325
+ // for the connection that is coming rather than be told about the one
326
+ // that just died.
327
+ this.established = false;
328
+ this.transport = null;
329
+ this.failInFlight();
330
+ // Before the listeners, not after. A disconnect listener may call
331
+ // `connect()` synchronously — the Panel registers such handlers
332
+ // (#156) — and with no transport and no backoff armed yet, that call
333
+ // would dial a socket of its own, which the backoff's own
334
+ // `openTransport()` then overwrote a moment later: one live link, one
335
+ // orphan nobody holds a reference to, and no `close()` coming for it.
336
+ // Arming first makes `reconnectScheduled()` true for that call, so it
337
+ // waits for the connection already on its way rather than opening a
338
+ // second one. Nothing observable moves: what this runs before the
339
+ // listeners either arms a timer or rejects a promise, and a promise
340
+ // rejection is a microtask that lands after the whole loop either way.
341
+ this.onConnectionClosed(reason);
342
+ for (const cb of this.disconnectedListeners)
343
+ cb({ error: reason });
344
+ },
345
+ },
346
+ });
347
+ }
348
+ /**
349
+ * The connection is up, authenticated if it had to be, and the capability off
350
+ * `ready` is known. What a fresh connection owes the Core goes here.
351
+ *
352
+ * `reclaim` is the whole of it for a one-shot client, and it is fire-and-forget
353
+ * by design (ADR 0024 D9): nothing waits on the answer, because the locks have
354
+ * already moved by the time it is written, and a reclaim that never lands costs
355
+ * the 45-second heartbeat timeout it was shortening — a slower path to the same
356
+ * state, never a wrong one.
357
+ *
358
+ * Overridden by {@link DurableCoreClient}, which owes the Core rather more.
359
+ */
360
+ onConnectionEstablished() {
361
+ this.sendReclaim();
362
+ }
363
+ /**
364
+ * Establish the connection once both halves of "established" are true: the
365
+ * Core has said who it is (`ready`), and the link can be written to (open, and
366
+ * authenticated when a bearer was configured).
367
+ *
368
+ * **Whichever lands last is what triggers it**, and that is not a fixed order.
369
+ * With a bearer it is `authOk`, well after `ready`. Without one, writability
370
+ * rides the socket opening and `ready` normally follows — but a Core that got
371
+ * its frame in first would otherwise have the connection's opening frames sent
372
+ * against a socket that is not writable yet, and a `subscribe` dropped there is
373
+ * a client that never receives an event again. Waiting for both removes the
374
+ * ordering assumption instead of relying on it.
375
+ */
376
+ maybeEstablish() {
377
+ if (this.established || this.closed)
378
+ return;
379
+ if (!this.ready || !this.transport?.writable)
380
+ return;
381
+ this.established = true;
382
+ this.onConnectionEstablished();
383
+ // The queue goes out behind what the connection owed the Core, so a replay
384
+ // tail is delivered ahead of the answers to requests that were waiting.
385
+ this.flushQueue();
386
+ this.settleConnect();
387
+ }
388
+ /** The socket is gone. A one-shot client is done; a durable one reconnects. */
389
+ onConnectionClosed(reason) {
390
+ if (this.closed)
391
+ return;
392
+ this.failConnect(new Error(reason ? `core-link closed: ${reason}` : "core-link closed"));
393
+ }
394
+ settleConnect() {
395
+ const settle = this.connectSettle;
396
+ if (!settle)
397
+ return;
398
+ this.connectSettle = null;
399
+ this.connectPromise = null;
400
+ clearTimeout(settle.timer);
401
+ settle.resolve(this.connectionInfo());
402
+ }
403
+ failConnect(err) {
404
+ const settle = this.connectSettle;
405
+ if (!settle)
406
+ return;
407
+ this.connectSettle = null;
408
+ // Dropped on failure as well as on success: a rejected promise held in the
409
+ // memo would be handed to every later caller forever. See `connect()`.
410
+ this.connectPromise = null;
411
+ clearTimeout(settle.timer);
412
+ settle.reject(err);
413
+ }
414
+ /** What the current connection said about itself. */
415
+ connectionInfo() {
416
+ return {
417
+ protocolVersion: this.ready?.version ?? null,
418
+ compatible: coreLinkProtocolCompatible(this.ready?.version ?? null),
419
+ multiConnection: this.multiConnection,
420
+ coreId: this.authOkFrame?.coreId ?? null,
421
+ bearerExpiresAt: this.authOkFrame?.exp ?? null,
422
+ };
423
+ }
424
+ /** True once the Core has accepted this connection's bearer. */
425
+ isAuthenticated() {
426
+ return this.transport?.isAuthenticated ?? false;
427
+ }
428
+ /** True while a link is up and able to carry a frame. */
429
+ isConnected() {
430
+ return this.transport?.writable ?? false;
431
+ }
432
+ /**
433
+ * May this Core be sent frames only a multi-connection Core understands — the
434
+ * Session lock's `claim`, the per-connection PTY subscription, and whatever
435
+ * else lands under ADR 0024?
436
+ *
437
+ * **The one predicate to consult before sending any such frame.** False means
438
+ * do not send it: not send-and-handle-the-error, not send-and-hope. The Core on
439
+ * the other end is a single-connection build that would answer an unknown frame
440
+ * with an error frame at best, and the caller is expected to have a
441
+ * single-connection path already — that path is what shipped before this
442
+ * capability existed.
443
+ *
444
+ * False before `ready` lands and after a drop, for as long as the answer is
445
+ * unknown. A caller that needs the capability at startup awaits
446
+ * {@link connect}, whose answer carries it, rather than reading this on the
447
+ * first tick.
448
+ */
449
+ canSendMultiConnectionFrames() {
450
+ return this.multiConnection !== null;
451
+ }
452
+ /**
453
+ * This connection's `multiConnection` capability, or null. For callers that
454
+ * need the version rather than the yes/no — the gate is
455
+ * {@link canSendMultiConnectionFrames}.
456
+ */
457
+ multiConnectionCapability() {
458
+ return this.multiConnection;
459
+ }
460
+ /** Hang up. Rejects every outstanding request; fires no `onDisconnected`. */
461
+ close() {
462
+ this.closed = true;
463
+ const err = new Error("core-link client closed");
464
+ for (const entry of [...this.queue, ...this.inFlight])
465
+ this.settle(entry, () => entry.reject(err));
466
+ this.queue.length = 0;
467
+ this.inFlight.clear();
468
+ this.failConnect(err);
469
+ this.transport?.close();
470
+ this.transport = null;
471
+ }
472
+ // ─── Requests ──────────────────────────────────────────────────────────────
473
+ /**
474
+ * Send any core-link request frame and resolve with the Core's raw response
475
+ * frame — errors included, as frames rather than rejections.
476
+ *
477
+ * This is the seam a router forwards through. A router that had to go through
478
+ * the typed methods below would unwrap each response only to re-wrap it, and
479
+ * would have to invent a shape for the failures those methods turn into
480
+ * rejections. Handing it the frame keeps it a router rather than a translator.
481
+ *
482
+ * The `reqId` on the frame passed in is ignored — the transport owns
483
+ * correlation on its own socket, and the returned frame carries the id it
484
+ * assigned. A caller matching answers to its own callers (the Panel does,
485
+ * across many browsers) keys on its own id and rewrites it on the way back.
486
+ *
487
+ * A frame issued while no link is up is **queued, not refused**: it goes out
488
+ * when one comes up, or its deadline ends it. That is what makes a call made
489
+ * during a reconnect work rather than needing every call site to retry.
490
+ */
491
+ request(frame, timeoutMs = this.requestTimeoutMs) {
492
+ if (this.closed)
493
+ return Promise.reject(new Error("core-link client closed"));
494
+ // The gate (ADR 0024 D11). A multi-connection-only frame aimed at a Core
495
+ // that never announced the capability is refused here, before anything
496
+ // reaches the socket — the Core never sees it, so there is no error frame to
497
+ // tolerate. The rejection is for the caller that asked without checking; the
498
+ // supported path is to consult `canSendMultiConnectionFrames()` and take the
499
+ // single-connection route.
500
+ if (this.multiConnectionOnlyFrameTypes.has(frame.type) && !this.canSendMultiConnectionFrames()) {
501
+ return Promise.reject(new Error(`core-link frame ${frame.type} needs the multiConnection capability, which this Core does not announce`));
502
+ }
503
+ return new Promise((resolve, reject) => {
504
+ const entry = { frame, resolve, reject, timer: null, settled: false };
505
+ entry.timer = setTimeout(() => {
506
+ this.forget(entry);
507
+ this.settle(entry, () => reject(new Error(`core-link rpc ${frame.type} timed out`)));
508
+ }, timeoutMs);
509
+ this.dispatch(entry);
510
+ });
511
+ }
512
+ /**
513
+ * Send now if there is an established link, otherwise hold it for the next
514
+ * one.
515
+ *
516
+ * **Established, not merely writable.** On the no-bearer path writability
517
+ * rides the socket opening and can land before `ready`, so a request *issued*
518
+ * in that window would go out ahead of the `reclaim` and `subscribe` this
519
+ * connection owes the Core — the ordering `onConnectionEstablished` documents
520
+ * as load-bearing, and the same property `onWritable`'s guard keeps for a
521
+ * request that was already queued. Both doors, one rule: nothing reaches the
522
+ * wire before the connection has been established (issue 153 review, #156).
523
+ */
524
+ dispatch(entry) {
525
+ const transport = this.transport;
526
+ if (!this.established || !transport?.writable) {
527
+ this.queue.push(entry);
528
+ return;
529
+ }
530
+ this.inFlight.add(entry);
531
+ transport.request(entry.frame).then((response) => {
532
+ this.inFlight.delete(entry);
533
+ this.settle(entry, () => entry.resolve(response));
534
+ }, (err) => {
535
+ this.inFlight.delete(entry);
536
+ this.settle(entry, () => entry.reject(err));
537
+ });
538
+ }
539
+ /** Drain what was waiting for a link, in the order it was asked for. */
540
+ flushQueue() {
541
+ if (this.queue.length === 0)
542
+ return;
543
+ const queued = this.queue.splice(0);
544
+ for (const entry of queued) {
545
+ if (entry.settled)
546
+ continue;
547
+ this.dispatch(entry);
548
+ }
549
+ }
550
+ /**
551
+ * Fail every request that was on the wire when the socket died. Their replies
552
+ * can never arrive, so leaving them pending only makes a caller wait out its
553
+ * whole deadline before it can retry on the next connection.
554
+ */
555
+ failInFlight() {
556
+ if (this.inFlight.size === 0)
557
+ return;
558
+ const err = new Error("core-link connection lost");
559
+ const entries = [...this.inFlight];
560
+ this.inFlight.clear();
561
+ for (const entry of entries)
562
+ this.settle(entry, () => entry.reject(err));
563
+ }
564
+ forget(entry) {
565
+ this.inFlight.delete(entry);
566
+ const at = this.queue.indexOf(entry);
567
+ if (at >= 0)
568
+ this.queue.splice(at, 1);
569
+ }
570
+ settle(entry, finish) {
571
+ if (entry.settled)
572
+ return;
573
+ entry.settled = true;
574
+ if (entry.timer)
575
+ clearTimeout(entry.timer);
576
+ entry.timer = null;
577
+ finish();
578
+ }
579
+ /** Send a request and unwrap the Core's answer into the value it carries. */
580
+ rpc(frame, timeoutMs) {
581
+ return this.request(frame, timeoutMs).then((response) => unwrapResponse(response));
582
+ }
583
+ /**
584
+ * Send a fire-and-forget frame on the current connection. False when there is
585
+ * no writable connection to put it on — for the frames whose answer is a
586
+ * stream and whose retry is the next connection re-sending them anyway.
587
+ */
588
+ sendNow(frame, reqIdPrefix) {
589
+ const transport = this.transport;
590
+ if (!transport)
591
+ return false;
592
+ return transport.send(frame, reqIdPrefix) !== null;
593
+ }
594
+ /**
595
+ * Present this client's Core client id, so the Core closes the socket this
596
+ * connection replaces and moves its Session locks here (ADR 0024 D9).
597
+ *
598
+ * The answer is not discarded, though (issue 147). `taskIds` names the Sessions
599
+ * whose locks came across, and that is a fact no layer above can derive: this
600
+ * connection did not claim them, the Core logged no event for the transfer —
601
+ * the locks moved in place, which is exactly the atomicity D9 needs — so until
602
+ * something asks for a fresh snapshot nothing else on the link says this client
603
+ * is holding them again. Hence {@link onReclaimed}.
604
+ *
605
+ * **Against a Core that does not announce `multiConnection`, this sends
606
+ * nothing** — the same consult-the-gate-and-degrade shape
607
+ * {@link ptySubscribe} and {@link claim} take, and here the degraded behaviour
608
+ * is not merely acceptable but identical: such a Core evicts the previous
609
+ * connection when this one opens, which is precisely what the frame asks for.
610
+ */
611
+ sendReclaim() {
612
+ if (!this.canSendMultiConnectionFrames())
613
+ return;
614
+ void this.rpc({ type: "reclaim", reqId: "", clientId: this.clientId })
615
+ .then((result) => {
616
+ const answer = result;
617
+ if (!answer)
618
+ return;
619
+ const msg = {
620
+ replaced: answer.replaced === true,
621
+ taskIds: Array.isArray(answer.taskIds) ? answer.taskIds : [],
622
+ };
623
+ for (const cb of this.reclaimedListeners) {
624
+ try {
625
+ cb(msg);
626
+ }
627
+ catch {
628
+ /* a listener's failure is not this connection's failure */
629
+ }
630
+ }
631
+ })
632
+ .catch(() => {
633
+ /* the socket died again; the next connection presents the same id */
634
+ });
635
+ }
636
+ // ─── The three unsolicited streams, plus the event log ─────────────────────
637
+ /**
638
+ * One PTY's bytes, as the transport parsed them. Nothing above re-parses a raw
639
+ * message: this is the frame, typed against the SDK's own schema.
640
+ *
641
+ * A Core sends a PTY's `data` only to the connections that asked for it, so
642
+ * {@link ptySubscribe} is what makes this fire at all against a
643
+ * multi-connection Core.
644
+ */
645
+ onData(cb) {
646
+ this.dataListeners.add(cb);
647
+ return () => this.dataListeners.delete(cb);
648
+ }
649
+ /** One PTY's exit, on the same terms as {@link onData}. */
650
+ onExit(cb) {
651
+ this.exitListeners.add(cb);
652
+ return () => this.exitListeners.delete(cb);
653
+ }
654
+ /**
655
+ * Notified on every connection's `ready` frame with what the Core says about
656
+ * itself — the protocol version, whether this build speaks it, and the
657
+ * `multiConnection` capability. Fires on every (re)connect, so a Core that
658
+ * comes back after an update reports itself compatible with no extra plumbing.
659
+ */
660
+ onReady(cb) {
661
+ this.readyListeners.add(cb);
662
+ return () => this.readyListeners.delete(cb);
663
+ }
664
+ /**
665
+ * Domain events off the Core's monotonic log — task status, hooks, session
666
+ * finish, PTY lifecycle. A one-shot client receives only what arrives while it
667
+ * is connected; the cursor, the replay and the dedupe that make a *gap*
668
+ * recoverable are {@link DurableCoreClient}'s.
669
+ */
670
+ onEvent(cb) {
671
+ this.eventListeners.add(cb);
672
+ return () => this.eventListeners.delete(cb);
673
+ }
674
+ /**
675
+ * Ask this Core for its event log: the tail past `lastEventId`, then live
676
+ * push for as long as this connection lasts.
677
+ *
678
+ * Fire-and-forget, because the answer is a *stream* — the replay tail as
679
+ * `event` frames and the {@link onEventsReplayed} marker behind them — rather
680
+ * than a reqId-correlated response. Returns false when there was no writable
681
+ * connection to put the frame on.
682
+ *
683
+ * A one-shot client sends this only if it is asked to. That is the difference
684
+ * between the two entry points here: {@link DurableCoreClient} owes the Core a
685
+ * subscribe on every connection and sends one through this method, whereas a
686
+ * program that connects to ask one question wants no event stream at all.
687
+ * What made it public is the session layer (issue 155): a script that starts a
688
+ * Session and waits for the harness to finish is reading the Core's own
689
+ * report of that, and the report is an event.
690
+ *
691
+ * `lastEventId` defaults to 0, which asks for the whole tail the Core will
692
+ * serve. That is the honest default for a caller with no cursor — the
693
+ * alternative is inventing a high-water mark, and a cursor that runs ahead of
694
+ * the log stops live push entirely. A caller that keeps a cursor passes it.
695
+ */
696
+ subscribeEvents(lastEventId = 0) {
697
+ if (this.closed)
698
+ return false;
699
+ this.eventsSubscribed = true;
700
+ return this.sendNow({ type: "subscribe", reqId: "", lastEventId }, "sub");
701
+ }
702
+ /**
703
+ * Has a `subscribe` gone out on this client's behalf?
704
+ *
705
+ * For a caller that needs the event stream but does not know whether the
706
+ * client it was handed is already receiving one — a durable client subscribes
707
+ * itself, a one-shot client does not, and a second subscribe would replay the
708
+ * tail a second time.
709
+ */
710
+ isSubscribedToEvents() {
711
+ return this.eventsSubscribed;
712
+ }
713
+ /** Notified when a `subscribe` replay tail has been fully streamed. */
714
+ onEventsReplayed(cb) {
715
+ this.eventsReplayedListeners.add(cb);
716
+ return () => this.eventsReplayedListeners.delete(cb);
717
+ }
718
+ /**
719
+ * Notified once per connection when the Core accepts the bearer. `exp` is the
720
+ * session expiry — a caller can hint "session expires at".
721
+ */
722
+ onAuthOk(cb) {
723
+ this.authOkListeners.add(cb);
724
+ return () => this.authOkListeners.delete(cb);
725
+ }
726
+ /**
727
+ * Notified when the Core refuses the bearer. The Core closes the socket right
728
+ * after; an expired bearer keeps failing until a reissued blob is pasted (ADR
729
+ * 0003).
730
+ */
731
+ onAuthError(cb) {
732
+ this.authErrorListeners.add(cb);
733
+ return () => this.authErrorListeners.delete(cb);
734
+ }
735
+ /**
736
+ * Notified whenever the socket goes down — a dropped connection, a Core
737
+ * restart, a dial that never completed. `close()` does not fire it: that is
738
+ * this client hanging up, not the Core going away.
739
+ */
740
+ onDisconnected(cb) {
741
+ this.disconnectedListeners.add(cb);
742
+ return () => this.disconnectedListeners.delete(cb);
743
+ }
744
+ /**
745
+ * What this client's `reclaim` reaped, once per connection that sent one (ADR
746
+ * 0024 D9, issue 147 is its consumer).
747
+ *
748
+ * Fires only on a Core that announces `multiConnection`, because only such a
749
+ * Core is sent the frame at all. `taskIds` is often empty and an empty list
750
+ * never means the id was unknown — it means this client held nothing when its
751
+ * previous socket went quiet, which is the ordinary case.
752
+ */
753
+ onReclaimed(cb) {
754
+ this.reclaimedListeners.add(cb);
755
+ return () => this.reclaimedListeners.delete(cb);
756
+ }
757
+ /** Hand one event to the listeners. Overridden where a cursor is kept. */
758
+ deliverEvent(event) {
759
+ for (const cb of this.eventListeners)
760
+ cb({ event });
761
+ }
762
+ /** Hand the end-of-replay marker on. Overridden where a cursor is kept. */
763
+ deliverEventsReplayed(lastEventId) {
764
+ for (const cb of this.eventsReplayedListeners)
765
+ cb({ lastEventId });
766
+ }
767
+ // ─── Typed frame methods ───────────────────────────────────────────────────
768
+ spawn(opts) {
769
+ return this.rpc({ type: "spawn", reqId: "", opts });
770
+ }
771
+ write(ptyId, data) {
772
+ return this.rpc({ type: "write", reqId: "", ptyId, data });
773
+ }
774
+ resize(ptyId, cols, rows) {
775
+ return this.rpc({ type: "resize", reqId: "", ptyId, cols, rows });
776
+ }
777
+ kill(ptyId) {
778
+ return this.rpc({ type: "kill", reqId: "", ptyId });
779
+ }
780
+ killLaunchProcesses(opts) {
781
+ return this.rpc({
782
+ type: "killLaunchProcesses",
783
+ reqId: "",
784
+ cwd: opts.cwd,
785
+ commands: opts.commands,
786
+ ports: opts.ports,
787
+ });
788
+ }
789
+ findByTask(taskId) {
790
+ return this.rpc({ type: "findByTask", reqId: "", taskId });
791
+ }
792
+ /**
793
+ * See {@link CoreLinkRequestFrame} `replay` — `sinceSeq` asks for the tail past
794
+ * a cursor (a reattach); omitting it asks for the whole scrollback.
795
+ */
796
+ replay(ptyId, sinceSeq) {
797
+ return this.rpc({ type: "replay", reqId: "", ptyId, sinceSeq });
798
+ }
799
+ /**
800
+ * Ask this Core for one PTY's byte stream (ADR 0024 D2). Until a client
801
+ * subscribes, {@link onData}/{@link onExit} never fire for that `ptyId` — a
802
+ * Core fans output out to the connections that asked and to no others.
803
+ *
804
+ * `catchUp` says a `replay` for this PTY follows and the Core must hold the
805
+ * live stream until it has been served, so the caller never paints live bytes
806
+ * in front of its own scrollback. A caller that sets it owes that replay;
807
+ * nothing else releases the hold.
808
+ *
809
+ * **Against a Core that does not announce `multiConnection`, this sends nothing
810
+ * and resolves** (ADR 0024 D11). That is the deliberate single-connection
811
+ * fallback, not a swallowed failure: such a Core fans every PTY out to every
812
+ * connection, so the caller is already receiving the bytes it just asked for,
813
+ * and the subscription it would send is a frame that Core has no vocabulary
814
+ * for. Resolving says what is true — this client renders that PTY — which is
815
+ * the only thing the caller acts on.
816
+ */
817
+ ptySubscribe(ptyId, opts = {}) {
818
+ if (!this.canSendMultiConnectionFrames())
819
+ return Promise.resolve();
820
+ return this.rpc({
821
+ type: "ptySubscribe",
822
+ reqId: "",
823
+ ptyId,
824
+ catchUp: opts.catchUp === true,
825
+ }).then(() => undefined);
826
+ }
827
+ /**
828
+ * Stop receiving one PTY's stream. Same fallback as {@link ptySubscribe}, for
829
+ * the same reason: against a capability-less Core there is no subscription to
830
+ * drop, because its fan-out is unconditional and cannot be narrowed by a frame
831
+ * it does not know.
832
+ */
833
+ ptyUnsubscribe(ptyId) {
834
+ if (!this.canSendMultiConnectionFrames())
835
+ return Promise.resolve();
836
+ return this.rpc({ type: "ptyUnsubscribe", reqId: "", ptyId }).then(() => undefined);
837
+ }
838
+ /**
839
+ * Take this Session's write lock (ADR 0024 D6).
840
+ *
841
+ * `granted: false` means another Core client holds it — an answer, not a
842
+ * failure, and the only way past it is {@link forceTakeover}. Idempotent for a
843
+ * client that already holds the Session.
844
+ *
845
+ * **Against a Core that does not announce `multiConnection`, this sends nothing
846
+ * and answers `{ supported: false, granted: false }`.** Such a Core evicts
847
+ * every client but one, so there is nothing for a lock to arbitrate between and
848
+ * no table to put a claim in.
849
+ *
850
+ * `supported: false` must never be rendered as read-only. It says this Core has
851
+ * no Session lock at all, so every mutation this client makes will be served —
852
+ * the opposite of what `granted: false` says. A caller that treats the two the
853
+ * same makes a single-connection Core look permanently locked to an operator
854
+ * who is in fact its only client.
855
+ */
856
+ claim(taskId) {
857
+ if (!this.canSendMultiConnectionFrames()) {
858
+ return Promise.resolve({ supported: false, granted: false });
859
+ }
860
+ return this.rpc({ type: "claim", reqId: "", taskId }).then((granted) => ({
861
+ supported: true,
862
+ granted: granted === true,
863
+ }));
864
+ }
865
+ /**
866
+ * Give this Session's write lock back (ADR 0024 D7).
867
+ *
868
+ * `released: false` means this client did not hold it — idempotent, not an
869
+ * error, so a caller releasing on teardown does not have to know whether it had
870
+ * already lost the lock to a takeover. Same capability fallback as
871
+ * {@link claim}: nothing goes on the wire to a Core with no lock table, and
872
+ * `supported: false` says there was never a lock to give back.
873
+ */
874
+ release(taskId) {
875
+ if (!this.canSendMultiConnectionFrames()) {
876
+ return Promise.resolve({ supported: false, released: false });
877
+ }
878
+ return this.rpc({ type: "release", reqId: "", taskId }).then((released) => ({
879
+ supported: true,
880
+ released: released === true,
881
+ }));
882
+ }
883
+ /**
884
+ * Take this Session's write lock whoever holds it (ADR 0024 D7).
885
+ *
886
+ * The answer to a hung client, and the reason the lock needs no idle timeout.
887
+ * Unrecoverable by design: the previous holder's in-flight keystrokes are gone
888
+ * and its next mutation is refused. `takenFrom` names who lost it so a caller
889
+ * can say so rather than infer it — taking an unheld Session is an ordinary
890
+ * claim by another name.
891
+ *
892
+ * Same capability fallback as {@link claim}, answering `takenFrom: "nobody"`:
893
+ * there was nobody to take it from, because there is no lock on such a Core to
894
+ * take.
895
+ */
896
+ forceTakeover(taskId) {
897
+ if (!this.canSendMultiConnectionFrames()) {
898
+ return Promise.resolve({ supported: false, takenFrom: "nobody" });
899
+ }
900
+ return this.rpc({ type: "forceTakeover", reqId: "", taskId }).then((takenFrom) => ({
901
+ supported: true,
902
+ takenFrom: (takenFrom ?? "nobody"),
903
+ }));
904
+ }
905
+ /**
906
+ * List every project on this Core as a live snapshot. The Core is the source of
907
+ * truth; a client holds none. The returned `path` is a machine path on the
908
+ * Core — only the Core can validate it.
909
+ */
910
+ projectsList() {
911
+ return this.rpc({ type: "projectsList", reqId: "" });
912
+ }
913
+ /**
914
+ * List every active (non-archived) task on this Core, optionally filtered to
915
+ * one project.
916
+ *
917
+ * `archivedCount` is how many archived rows the same scope holds — a scalar,
918
+ * never the rows (ADR 0019). Use {@link archivedTasksList} for those.
919
+ */
920
+ tasksList(projectId) {
921
+ return this.rpc({ type: "tasksList", reqId: "", projectId });
922
+ }
923
+ /**
924
+ * List every archived task on this Core, optionally filtered to one project
925
+ * (ADR 0019) — a separate frame from {@link tasksList}, so an active answer
926
+ * stays free of archived rows by construction rather than by what a caller
927
+ * remembers to pass.
928
+ */
929
+ archivedTasksList(projectId) {
930
+ return this.rpc({ type: "archivedTasksList", reqId: "", projectId });
931
+ }
932
+ /**
933
+ * Create / rename / archive a project on this Core. The Core validates the
934
+ * machine path server-side; an invalid path comes back as an `error` frame that
935
+ * rejects this promise. Returns `null` when a `rename`/`archive` targets a
936
+ * missing row.
937
+ */
938
+ projectsMutate(mutation) {
939
+ return this.rpc({ type: "projectsMutate", reqId: "", mutation });
940
+ }
941
+ /** Create / update / delete a task. Returns `null` when it targets a missing row. */
942
+ tasksMutate(mutation) {
943
+ return this.rpc({ type: "tasksMutate", reqId: "", mutation });
944
+ }
945
+ /**
946
+ * List every active session on this Core (optionally filtered to one project).
947
+ * A session's `ptyId` is set when the Core has a live PTY for that task — which
948
+ * is how a client knows what it can reattach to.
949
+ */
950
+ sessionsList(projectId) {
951
+ return this.rpc({ type: "sessionsList", reqId: "", projectId });
952
+ }
953
+ /**
954
+ * Snapshot of this Core's CLI availability. Live updates arrive as
955
+ * `agents:availabilityChanged` events on {@link onEvent}; this is how a client
956
+ * hydrates without waiting for the next probe tick.
957
+ */
958
+ agentsAvailabilityList() {
959
+ return this.rpc({ type: "agentsAvailabilityList", reqId: "" });
960
+ }
961
+ }
962
+ /**
963
+ * Turn a response frame into the value the typed methods promise, or throw the
964
+ * failure it carries. The two failure frames (`spawnError`, `error`) become
965
+ * rejections here so a caller of `spawn()` sees an exception rather than a frame
966
+ * it has to inspect; {@link CoreClient.request} bypasses this and hands the frame
967
+ * over untouched.
968
+ */
969
+ export function unwrapResponse(msg) {
970
+ switch (msg.type) {
971
+ case "spawned":
972
+ return { ptyId: msg.ptyId };
973
+ case "spawnError":
974
+ throw new Error(msg.message);
975
+ case "writeResult":
976
+ case "resizeResult":
977
+ case "killResult":
978
+ return msg.ok;
979
+ case "killLaunchProcessesResult":
980
+ return msg.result;
981
+ case "findByTaskResult":
982
+ return { ptyId: msg.ptyId };
983
+ case "replayResult":
984
+ return { data: msg.data, nextSeq: msg.nextSeq, from: msg.from };
985
+ // The active list answers with rows *and* the archived count (ADR 0019), so
986
+ // unwrapping to `msg.tasks` alone would drop a required field of the frame.
987
+ // The archived list carries rows only.
988
+ case "tasksListResult":
989
+ return { tasks: msg.tasks, archivedCount: msg.archivedCount };
990
+ case "archivedTasksListResult":
991
+ return msg.tasks;
992
+ case "projectsListResult":
993
+ return msg.projects;
994
+ case "tasksMutateResult":
995
+ return msg.task;
996
+ case "projectsMutateResult":
997
+ return msg.project;
998
+ case "sessionsListResult":
999
+ return msg.sessions;
1000
+ case "agentsAvailabilityListResult":
1001
+ return msg.availability;
1002
+ case "ptySubscribeAck":
1003
+ case "ptyUnsubscribeAck":
1004
+ return { ptyId: msg.ptyId, subscribed: msg.subscribed };
1005
+ // The Session lock's three answers unwrap to the one field each carries
1006
+ // beyond the taskId the caller already passed in (issue 144). A denied claim
1007
+ // comes back here as `false` rather than as a rejection — it is an answer,
1008
+ // not a failure, and only a *mutation* refused for the lock throws (that is
1009
+ // an `error` frame, below, carrying `session-locked`).
1010
+ case "claimResult":
1011
+ return msg.granted;
1012
+ case "releaseResult":
1013
+ return msg.released;
1014
+ case "forceTakeoverResult":
1015
+ return msg.takenFrom;
1016
+ // Both fields, because both are reporting a caller cannot derive (issue 146).
1017
+ // `taskIds` in particular is the set of Sessions whose locks came across with
1018
+ // this connection, and a lock register is its consumer: after a reconnect
1019
+ // those Sessions are held-by-you again, and nothing else on the link would
1020
+ // say so until the next snapshot.
1021
+ case "reclaimResult":
1022
+ return { replaced: msg.replaced, taskIds: msg.taskIds };
1023
+ // The code rides the rejection rather than being dropped at the boundary
1024
+ // (issue 144): a caller can already tell "locked" (throws) from "gone"
1025
+ // (resolves), and this is what lets it tell "locked" from any other error
1026
+ // without reading the prose the field exists to spare it.
1027
+ case "error":
1028
+ throw new CoreLinkRequestError(msg.message, msg.code);
1029
+ default:
1030
+ // `ready`, `subscribeAck`, the auth frames — not request/response answers
1031
+ // any typed method awaits. Resolving undefined matches the Panel's
1032
+ // behaviour of leaving such a frame unhandled rather than rejecting on it.
1033
+ return undefined;
1034
+ }
1035
+ }
1036
+ //# sourceMappingURL=core-client.js.map