@nanobpm/urban-agent-client 0.1.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.
package/dist/client.js ADDED
@@ -0,0 +1,706 @@
1
+ import { OutboundRing } from "./ring.js";
2
+ import { MAX_SEQ, decodeFrame, encodeFrame, isMessageFamily, validatePayload, } from "./protocol.js";
3
+ import { websocketTransport } from "./transport.js";
4
+ /**
5
+ * The lane each outbound family rides. Presence, coordination and vocab are
6
+ * control/facts; relay bytes are bulk. This is the client's contribution to
7
+ * invariant #5 — a relay storm on the bulk lane can never head-of-line-block a
8
+ * heartbeat or deregister on the control lane.
9
+ */
10
+ const OUTBOUND_LANE = {
11
+ register: "control",
12
+ heartbeat: "control",
13
+ deregister: "control",
14
+ relay: "bulk",
15
+ };
16
+ const DEFAULT_BUFFER_CAPACITY = 1024;
17
+ const DEFAULT_SERVE_TIMEOUT_MS = 30_000;
18
+ const DEFAULT_RECONNECT = {
19
+ enabled: true,
20
+ initialDelayMs: 250,
21
+ maxDelayMs: 10_000,
22
+ factor: 2,
23
+ };
24
+ /**
25
+ * Guard a resolved timing/backoff option. Node's setTimeout/setInterval coerce a
26
+ * negative or NaN delay to 0, which would turn a misconfigured heartbeat or
27
+ * reconnect backoff into a tight, event-loop-saturating loop. Reject such values
28
+ * at construction — the same fail-fast contract OutboundRing applies to capacity —
29
+ * instead of silently degrading into a hot loop at runtime.
30
+ */
31
+ function assertFiniteAtLeast(name, value, min) {
32
+ if (!Number.isFinite(value) || value < min) {
33
+ throw new RangeError(`${name} must be a finite number >= ${min}, got ${value}`);
34
+ }
35
+ }
36
+ /**
37
+ * Worker-side client for the Nano agentic channel (S9).
38
+ *
39
+ * Speaks the S0 wire contract on a connection SEPARATE from the C8 job protocol
40
+ * (invariants #1/#2): it registers a capability, receives its resolved `SERVE`
41
+ * tokens, heartbeats/deregisters, and produces relay bytes. Everything the
42
+ * worker produces goes through a bounded {@link OutboundRing}, so the worker
43
+ * keeps producing across a hub outage and drains — in strict QoS order — on
44
+ * reconnect (invariants #5/#6). Capability is an enrolment attribute, never a
45
+ * routing token (invariant #3).
46
+ */
47
+ export class AgenticClient {
48
+ instance;
49
+ url;
50
+ transportFactory;
51
+ ring;
52
+ heartbeatIntervalMs;
53
+ serveTimeoutMs;
54
+ reconnectPolicy;
55
+ schedule;
56
+ capability;
57
+ transport;
58
+ state = "idle";
59
+ seq = 0;
60
+ reconnectDelay;
61
+ reconnecting = false;
62
+ closedByCaller = false;
63
+ closeHandled = false;
64
+ heartbeatTimer;
65
+ pendingServe;
66
+ lastServe = [];
67
+ relayOffsets = new Map();
68
+ serveListeners = new Set();
69
+ frameListeners = new Set();
70
+ openListeners = new Set();
71
+ closeListeners = new Set();
72
+ errorListeners = new Set();
73
+ drainListeners = new Set();
74
+ constructor(options) {
75
+ this.url = options.url;
76
+ this.instance = options.instance ?? crypto.randomUUID();
77
+ this.capability = options.capability;
78
+ this.transportFactory = options.transport ?? websocketTransport;
79
+ this.ring = new OutboundRing({ capacity: options.bufferCapacity ?? DEFAULT_BUFFER_CAPACITY });
80
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 0;
81
+ this.serveTimeoutMs = options.serveTimeoutMs ?? DEFAULT_SERVE_TIMEOUT_MS;
82
+ this.reconnectPolicy = {
83
+ enabled: options.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled,
84
+ initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs,
85
+ maxDelayMs: options.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT.maxDelayMs,
86
+ factor: options.reconnect?.factor ?? DEFAULT_RECONNECT.factor,
87
+ };
88
+ this.reconnectDelay = this.reconnectPolicy.initialDelayMs;
89
+ // Reject timing/backoff options that Node would coerce into a 0ms hot loop.
90
+ // heartbeat/serveTimeout accept 0 as a "disabled" sentinel (guarded with > 0
91
+ // at use), but reconnect delays have no such sentinel — enabled:false disables
92
+ // reconnect — so a 0ms initial/max delay is only ever a hot loop (0 * factor
93
+ // stays 0, and maxDelayMs clamps every backoff back to 0): require >= 1.
94
+ assertFiniteAtLeast("heartbeatIntervalMs", this.heartbeatIntervalMs, 0);
95
+ assertFiniteAtLeast("serveTimeoutMs", this.serveTimeoutMs, 0);
96
+ assertFiniteAtLeast("reconnect.initialDelayMs", this.reconnectPolicy.initialDelayMs, 1);
97
+ assertFiniteAtLeast("reconnect.maxDelayMs", this.reconnectPolicy.maxDelayMs, 1);
98
+ assertFiniteAtLeast("reconnect.factor", this.reconnectPolicy.factor, 1);
99
+ this.schedule =
100
+ options.schedule ??
101
+ ((fn, ms) => {
102
+ // Match the serve-timeout and heartbeat timers: an auto-reconnect backoff
103
+ // timer must not keep the Node event loop alive on its own.
104
+ const timer = setTimeout(fn, ms);
105
+ if (typeof timer.unref === "function") {
106
+ timer.unref();
107
+ }
108
+ });
109
+ }
110
+ /** Current connection state. */
111
+ get connectionState() {
112
+ return this.state;
113
+ }
114
+ /** True when the transport is open and draining live. */
115
+ get connected() {
116
+ return this.state === "open";
117
+ }
118
+ /** Number of frames currently buffered awaiting a live channel. */
119
+ get buffered() {
120
+ return this.ring.size;
121
+ }
122
+ /** The most recently resolved SERVE token set (empty until the first SERVE). */
123
+ get serve() {
124
+ return this.lastServe;
125
+ }
126
+ /** Open the transport. Safe to call once; reconnects are automatic. A no-op after close() (terminal). */
127
+ connect() {
128
+ // A caller-initiated close() is terminal: once closed, connect() is a no-op
129
+ // so a shut-down client never silently reopens (and never re-drains frames
130
+ // buffered before the shutdown). Manual reconnect after a passive drop is
131
+ // still available from the "idle" state (reconnect disabled).
132
+ if (this.state === "connecting" || this.state === "open" || this.state === "closed") {
133
+ return;
134
+ }
135
+ this.closedByCaller = false;
136
+ this.openTransport();
137
+ }
138
+ /**
139
+ * Declare a capability and await the resolved SERVE tokens.
140
+ *
141
+ * The REGISTER frame is buffered like any other outbound frame, so calling
142
+ * `register` while the hub is down does not fail — it enqueues and resolves
143
+ * once the channel comes back and the hub answers with SERVE. Capability is an
144
+ * enrolment attribute; it never becomes part of a routing token (invariant #3).
145
+ */
146
+ register(input) {
147
+ // A closed client is terminal: its buffer is released and the transport can
148
+ // never reopen, so a register here could only create a pending promise that
149
+ // never resolves and buffer a frame that never drains. Fail fast instead.
150
+ if (this.isClosed) {
151
+ return Promise.reject(new Error("register on a closed client"));
152
+ }
153
+ const capability = input?.capability ?? this.capability;
154
+ if (capability === undefined) {
155
+ return Promise.reject(new Error("register requires a capability (pass one, or set it in the client options)"));
156
+ }
157
+ this.capability = capability;
158
+ // A fresh register supersedes any in-flight one: reject the prior promise
159
+ // AND drop any REGISTER still buffered in the ring, so a reconnect drains
160
+ // only the newest capability (and never a stale duplicate ahead of it).
161
+ this.rejectPendingServe(new Error("superseded by a newer register"));
162
+ this.removeBuffered("register");
163
+ const promise = new Promise((resolve, reject) => {
164
+ const pending = { resolve, reject };
165
+ this.pendingServe = pending;
166
+ // Route the serve-timeout through the injectable scheduler (which unref()s
167
+ // the underlying timer by default, so a lingering register never keeps a
168
+ // process alive). A raw unref()'d setTimeout was the sole event-loop keeper
169
+ // during a caller's `await client.register()`, so under Node's test runner
170
+ // the loop could drain and cancel the awaited promise before the timer
171
+ // fired ("Promise resolution is still pending but the event loop has
172
+ // already resolved"). The scheduler is cancellation-free, so a stale fire
173
+ // after the pending was resolved/superseded is a guarded no-op.
174
+ if (this.serveTimeoutMs > 0) {
175
+ this.schedule(() => {
176
+ if (this.pendingServe !== pending) {
177
+ return;
178
+ }
179
+ this.pendingServe = undefined;
180
+ reject(new Error(`SERVE not received within ${this.serveTimeoutMs}ms`));
181
+ }, this.serveTimeoutMs);
182
+ }
183
+ });
184
+ this.enqueueRegister(capability);
185
+ if (this.state === "idle") {
186
+ this.connect();
187
+ }
188
+ if (this.heartbeatIntervalMs > 0) {
189
+ this.startHeartbeatTimer();
190
+ }
191
+ return promise;
192
+ }
193
+ /** Produce a single liveness heartbeat (control lane). Ages out on TTL if it stops (S2). */
194
+ heartbeat() {
195
+ if (this.refuseWhenClosed("heartbeat")) {
196
+ return;
197
+ }
198
+ // A heartbeat is point-in-time liveness, not durable state: only the newest
199
+ // one matters. Coalesce any heartbeat still buffered from a prior tick before
200
+ // enqueuing this one, so at most a single heartbeat is ever buffered. Without
201
+ // this, an auto-heartbeat timer running through a long outage would pile
202
+ // never-evicted control-lane heartbeats into the bounded ring and shed the
203
+ // buffered bulk relay (worker output) via the QoS overflow policy.
204
+ this.removeBuffered("heartbeat");
205
+ const payload = { instance: this.instance };
206
+ this.enqueue("heartbeat", OUTBOUND_LANE.heartbeat, payload);
207
+ }
208
+ /**
209
+ * Produce relay bytes on the bulk lane. `chunk` is the terminal/command output
210
+ * for `stream`; the client tracks a monotonic per-stream byte offset so the
211
+ * hub-side ring can resume-from-offset after a consumer reconnect (S5). Bytes
212
+ * are UTF-8-encoded on the wire as the payload's `chunk` string.
213
+ */
214
+ relay(stream, chunk) {
215
+ // Terminal client: refuse before touching the per-stream offset map (which
216
+ // close() already released) so a post-close relay neither re-grows that map
217
+ // nor buffers a frame that can never drain.
218
+ if (this.refuseWhenClosed("relay")) {
219
+ return;
220
+ }
221
+ const offset = this.relayOffsets.get(stream) ?? 0;
222
+ const payload = { stream, offset, chunk };
223
+ // Advance the per-stream offset only if the frame was actually accepted
224
+ // for sending. A relay rejected for invalid payload, refused because the
225
+ // client is closed, OR dropped by the QoS overflow policy (ring full of
226
+ // higher-priority traffic) must not consume offset space, or every
227
+ // subsequent relay's offset would be inconsistent with the bytes the hub
228
+ // actually received.
229
+ if (this.enqueue("relay", OUTBOUND_LANE.relay, payload)) {
230
+ this.relayOffsets.set(stream, offset + byteLength(chunk));
231
+ }
232
+ }
233
+ /**
234
+ * Deregister and close. Sends a deregister frame best-effort — only when the
235
+ * channel is currently open. `close()` is terminal and releases the buffer, so
236
+ * a deregister enqueued while disconnected could never drain; enqueuing it then
237
+ * would just pin an unsendable frame until close() drops it. When the channel
238
+ * is down we therefore skip the frame and tear down directly, without
239
+ * reconnecting.
240
+ */
241
+ deregister(reason) {
242
+ if (this.state === "open") {
243
+ const payload = reason === undefined ? { instance: this.instance } : { instance: this.instance, reason };
244
+ this.enqueue("deregister", OUTBOUND_LANE.deregister, payload);
245
+ }
246
+ this.close();
247
+ }
248
+ /** Tear down the client: stop the heartbeat, close the transport, stop reconnecting. */
249
+ close() {
250
+ this.closedByCaller = true;
251
+ this.stopHeartbeatTimer();
252
+ this.rejectPendingServe(new Error("client closed"));
253
+ // Best-effort transport close, then own the state transition + close event
254
+ // ourselves. A real transport's close is asynchronous (a WebSocket fires its
255
+ // own onClose on a later tick), and it may never surface a close at all, so
256
+ // we drive handleClose directly to guarantee onClose fires. handleClose is
257
+ // idempotent per connection attempt, so it de-duplicates against any onClose
258
+ // the transport also fires.
259
+ // Terminal: the outbound ring and per-stream relay offsets can never be
260
+ // drained again, so release them here rather than pinning a large outage
261
+ // backlog (buffered frames, many relay streams) in memory for the lifetime
262
+ // of the now-dead client. Clear BEFORE anything can fire the close event —
263
+ // both the transport (an injectable seam that may legally fire onClose
264
+ // synchronously from close(), as FakeTransport does when open) and our own
265
+ // handleClose below emit onClose synchronously. A subscriber that reads
266
+ // `buffered` (or the relay-offset state) must observe the released,
267
+ // self-consistent terminal state that close() documents — not a stale
268
+ // non-zero backlog — regardless of which path surfaces the close first.
269
+ this.ring.clear();
270
+ this.relayOffsets.clear();
271
+ const transport = this.transport;
272
+ this.transport = undefined;
273
+ try {
274
+ transport?.close();
275
+ }
276
+ catch {
277
+ // An already-broken transport may throw on close; the teardown proceeds.
278
+ }
279
+ this.handleClose({ local: true });
280
+ // Enforce the terminal state unconditionally. handleClose sets "closed" on
281
+ // the normal path, but it early-returns on its closeHandled guard when a
282
+ // prior close already fired for this connection attempt — e.g. a send-failure
283
+ // forceReconnect() left us "connecting" with a reconnect scheduled. Without
284
+ // this, close() would leave a non-terminal state: isClosed stays false, and
285
+ // (closedByCaller now set) the scheduled reconnect skips openTransport,
286
+ // wedging the client in "connecting". Own the terminal transition here so
287
+ // close() always honors its contract regardless of which path handled the
288
+ // close first.
289
+ this.state = "closed";
290
+ }
291
+ /** Subscribe to resolved SERVE tokens (fires on every SERVE, including reconnects). */
292
+ onServe(listener) {
293
+ this.serveListeners.add(listener);
294
+ return () => this.serveListeners.delete(listener);
295
+ }
296
+ /** Subscribe to every validated inbound frame. */
297
+ onFrame(listener) {
298
+ this.frameListeners.add(listener);
299
+ return () => this.frameListeners.delete(listener);
300
+ }
301
+ /** Subscribe to channel-open events (fires on first connect and each reconnect). */
302
+ onOpen(listener) {
303
+ this.openListeners.add(listener);
304
+ return () => this.openListeners.delete(listener);
305
+ }
306
+ /** Subscribe to channel-close events. */
307
+ onClose(listener) {
308
+ this.closeListeners.add(listener);
309
+ return () => this.closeListeners.delete(listener);
310
+ }
311
+ /** Subscribe to transport / decode / validation errors (never thrown; always non-fatal). */
312
+ onError(listener) {
313
+ this.errorListeners.add(listener);
314
+ return () => this.errorListeners.delete(listener);
315
+ }
316
+ /** Subscribe to buffer-drained events (fires when the outbound ring empties after sending). */
317
+ onDrain(listener) {
318
+ this.drainListeners.add(listener);
319
+ return () => this.drainListeners.delete(listener);
320
+ }
321
+ // ---- internals -----------------------------------------------------------
322
+ openTransport() {
323
+ this.state = "connecting";
324
+ this.closeHandled = false;
325
+ try {
326
+ this.transport = this.transportFactory(this.url, {
327
+ onOpen: () => this.handleOpen(),
328
+ onFrame: (bytes) => this.handleFrame(bytes),
329
+ onClose: (info) => this.handleClose(info),
330
+ onError: (error) => this.emitError(error),
331
+ });
332
+ }
333
+ catch (error) {
334
+ // A transport factory can throw synchronously — e.g. the default
335
+ // websocketTransport when no global WebSocket is available. Without this
336
+ // guard the exception escapes openTransport() and the client wedges in
337
+ // "connecting" with no transport and no signal. Surface it as a non-fatal
338
+ // error and drive handleClose so the client leaves "connecting" the same
339
+ // way a failed connection would: scheduling a reconnect when enabled, or
340
+ // going idle/closed otherwise.
341
+ this.transport = undefined;
342
+ this.emitError(error instanceof Error ? error : new Error(String(error)));
343
+ this.handleClose({ reason: "transport factory failed" });
344
+ }
345
+ }
346
+ handleOpen() {
347
+ this.state = "open";
348
+ this.reconnectDelay = this.reconnectPolicy.initialDelayMs;
349
+ // Re-announce presence first so a reconnect re-registers before draining
350
+ // any buffered relay backlog. Coalesce any REGISTER already buffered
351
+ // (possibly behind other control-lane frames a caller queued during the
352
+ // outage, e.g. a heartbeat) and enqueue a fresh one at the front of the
353
+ // control lane, so it drains ahead of the entire backlog — never behind a
354
+ // heartbeat and never as a stale duplicate.
355
+ if (this.capability !== undefined) {
356
+ this.removeBuffered("register");
357
+ this.enqueueRegister(this.capability, true);
358
+ // A capability set in options auto-registers here without register() ever
359
+ // being called, so start the documented auto-heartbeat timer on open too
360
+ // — otherwise heartbeatIntervalMs would silently no-op for auto-register
361
+ // consumers. startHeartbeatTimer() is idempotent, so an explicit
362
+ // register() that already started it is unaffected.
363
+ if (this.heartbeatIntervalMs > 0) {
364
+ this.startHeartbeatTimer();
365
+ }
366
+ }
367
+ this.emitOpen();
368
+ this.pump();
369
+ }
370
+ handleClose(info) {
371
+ // Idempotent per connection attempt: a send failure may route us here
372
+ // directly AND the transport may still fire its own onClose. Handle once.
373
+ if (this.closeHandled) {
374
+ return;
375
+ }
376
+ this.closeHandled = true;
377
+ this.transport = undefined;
378
+ if (this.closedByCaller) {
379
+ this.state = "closed";
380
+ }
381
+ else if (this.reconnectPolicy.enabled) {
382
+ this.state = "connecting";
383
+ }
384
+ else {
385
+ // No auto-reconnect: go idle so the caller can reconnect manually.
386
+ this.state = "idle";
387
+ }
388
+ // Emit exactly once per handled close (the closeHandled guard above makes
389
+ // this once-per-connection-attempt). Fire regardless of whether we reached
390
+ // "open": a caller-initiated close while still "connecting" must notify
391
+ // onClose just like a remote drop while connecting already does — otherwise
392
+ // onClose is silent for `connect()` immediately followed by `close()`.
393
+ this.emitClose(info);
394
+ if (!this.closedByCaller && this.reconnectPolicy.enabled) {
395
+ this.scheduleReconnect();
396
+ }
397
+ }
398
+ scheduleReconnect() {
399
+ if (this.reconnecting) {
400
+ return;
401
+ }
402
+ this.reconnecting = true;
403
+ const delay = this.reconnectDelay;
404
+ this.reconnectDelay = Math.min(this.reconnectDelay * this.reconnectPolicy.factor, this.reconnectPolicy.maxDelayMs);
405
+ this.schedule(() => {
406
+ this.reconnecting = false;
407
+ if (!this.closedByCaller) {
408
+ this.openTransport();
409
+ }
410
+ }, delay);
411
+ }
412
+ handleFrame(bytes) {
413
+ let frame;
414
+ try {
415
+ frame = decodeFrame(bytes);
416
+ }
417
+ catch (error) {
418
+ // Malformed input from the wire must never crash the worker — surface it
419
+ // and keep the channel alive. This is what the conformance corpus's
420
+ // malformed vectors exercise.
421
+ this.emitError(asError(error, "failed to decode inbound frame"));
422
+ return;
423
+ }
424
+ const check = validatePayload(frame.family, frame.payload);
425
+ if (!check.ok) {
426
+ this.emitError(new Error(`inbound ${frame.family} payload failed validation: ${check.errors.map((e) => e.code).join(",")}`));
427
+ return;
428
+ }
429
+ this.emitFrame(frame);
430
+ if (frame.family === "serve") {
431
+ this.handleServe(frame.payload);
432
+ }
433
+ }
434
+ handleServe(payload) {
435
+ if (!isServePayload(payload) || payload.instance !== this.instance) {
436
+ return;
437
+ }
438
+ this.lastServe = payload.tokens;
439
+ if (this.pendingServe !== undefined) {
440
+ const pending = this.pendingServe;
441
+ this.pendingServe = undefined;
442
+ pending.resolve({ serve: payload.tokens });
443
+ }
444
+ this.emitServe(payload);
445
+ }
446
+ enqueueRegister(capability, front = false) {
447
+ const payload = { instance: this.instance, capability };
448
+ const invalid = this.rejectInvalidOutbound("register", payload);
449
+ if (invalid) {
450
+ return;
451
+ }
452
+ if (front) {
453
+ const frame = { lane: OUTBOUND_LANE.register, family: "register", seq: this.nextSeq(), payload };
454
+ this.ring.enqueueFront(frame);
455
+ this.pump();
456
+ return;
457
+ }
458
+ this.enqueue("register", OUTBOUND_LANE.register, payload);
459
+ }
460
+ enqueue(family, lane, payload) {
461
+ if (this.refuseWhenClosed(family)) {
462
+ return false;
463
+ }
464
+ if (this.rejectInvalidOutbound(family, payload)) {
465
+ return false;
466
+ }
467
+ const frame = { lane, family, seq: this.nextSeq(), payload };
468
+ // The QoS overflow policy can drop the INCOMING frame when the ring is full
469
+ // of strictly higher-priority traffic (it returns the frame as `evicted` and
470
+ // leaves the buffer untouched). Report that as not-accepted so offset-tracking
471
+ // callers (relay) don't advance past bytes that never entered the buffer.
472
+ const { evicted } = this.ring.enqueue(frame);
473
+ this.pump();
474
+ return evicted !== frame;
475
+ }
476
+ /** True once close() has been called — the terminal state (see {@link close}). */
477
+ get isClosed() {
478
+ return this.state === "closed";
479
+ }
480
+ /**
481
+ * Categorical guard for every outbound-producing surface: once the client is
482
+ * closed (terminal), the transport can never reopen and the buffer has been
483
+ * released, so any frame produced here can never drain. Rather than silently
484
+ * re-grow the ring close() emptied — and mislead the caller — refuse and
485
+ * surface the misuse via onError. Returns true when the call was refused.
486
+ */
487
+ refuseWhenClosed(family) {
488
+ if (!this.isClosed) {
489
+ return false;
490
+ }
491
+ this.emitError(new Error(`cannot ${family} on a closed client`));
492
+ return true;
493
+ }
494
+ /**
495
+ * Validate an outbound payload against the S0 contract before it is buffered.
496
+ * An unsendable frame is never enqueued (so it can't silently occupy buffer
497
+ * space and then be dropped at encode time); the error is surfaced and, when
498
+ * it is the REGISTER we are awaiting a SERVE for, the register promise is
499
+ * failed fast instead of hanging until the serve timeout (or forever when the
500
+ * timeout is disabled). Returns true when the payload was rejected.
501
+ */
502
+ rejectInvalidOutbound(family, payload) {
503
+ const check = validatePayload(family, payload);
504
+ if (check.ok) {
505
+ return false;
506
+ }
507
+ const error = new Error(`outbound ${family} payload failed validation: ${check.errors.map((e) => e.code).join(",")}`);
508
+ if (family === "register") {
509
+ this.rejectPendingServe(error);
510
+ }
511
+ this.emitError(error);
512
+ return true;
513
+ }
514
+ /** Drain the ring to the transport in strict QoS order until it empties or a send fails. */
515
+ pump() {
516
+ if (this.state !== "open" || this.transport === undefined) {
517
+ return;
518
+ }
519
+ let sentAny = false;
520
+ while (!this.ring.isEmpty) {
521
+ const frame = this.ring.peek();
522
+ if (frame === undefined) {
523
+ break;
524
+ }
525
+ let bytes;
526
+ try {
527
+ bytes = encodeFrame(frame);
528
+ }
529
+ catch (error) {
530
+ // An unencodable frame can never be sent — drop it rather than wedge the
531
+ // drain, and report it. If it was the REGISTER we are awaiting a SERVE
532
+ // for, fail that promise fast instead of leaving it pending until (or
533
+ // beyond) the serve timeout.
534
+ this.ring.dequeue();
535
+ const err = asError(error, "failed to encode outbound frame; dropped");
536
+ if (frame.family === "register") {
537
+ this.rejectPendingServe(err);
538
+ }
539
+ this.emitError(err);
540
+ continue;
541
+ }
542
+ try {
543
+ this.transport.send(bytes);
544
+ }
545
+ catch (error) {
546
+ // The channel went away mid-drain: leave the frame buffered and stop.
547
+ // The transport is not required to also fire onClose (the send contract
548
+ // only mandates a synchronous throw), so drive the disconnect/reconnect
549
+ // ourselves rather than relying on an onClose that may never arrive —
550
+ // otherwise a throw-only transport wedges the client with a full buffer.
551
+ this.emitError(asError(error, "transport send failed; reconnecting"));
552
+ this.forceReconnect({ local: false });
553
+ break;
554
+ }
555
+ this.ring.dequeue();
556
+ sentAny = true;
557
+ }
558
+ if (sentAny && this.ring.isEmpty) {
559
+ this.emitDrain();
560
+ }
561
+ }
562
+ /**
563
+ * Drop every buffered frame of `family` from the ring, returning them. The
564
+ * single source of truth for outbound coalescing: register coalescing (a
565
+ * superseding `register()` and a reconnect's `handleOpen()`) and heartbeat
566
+ * coalescing both use it so the drain never emits a stale or duplicate frame.
567
+ */
568
+ removeBuffered(family) {
569
+ return this.ring.remove((frame) => frame.family === family);
570
+ }
571
+ /**
572
+ * Tear down the current transport and route through the normal close/reconnect
573
+ * path when a send throws but the transport does not (or has not yet) fired its
574
+ * own onClose. `handleClose` is idempotent for this connection attempt, so if
575
+ * the transport DOES also emit onClose the second pass is a no-op.
576
+ */
577
+ forceReconnect(info) {
578
+ const transport = this.transport;
579
+ this.transport = undefined;
580
+ // Drive the close with the intended `info` FIRST, then tear down the
581
+ // transport. `handleClose` is idempotent per connection attempt, so if
582
+ // closing the transport synchronously fires its own onClose (the bundled
583
+ // FakeTransport does, with `{ local: true }`), that second pass is a no-op
584
+ // and cannot misreport this remote drop / send failure as a local close.
585
+ this.handleClose(info);
586
+ try {
587
+ transport?.close();
588
+ }
589
+ catch {
590
+ // A transport that is already broken may throw on close; ignore it.
591
+ }
592
+ }
593
+ nextSeq() {
594
+ const value = this.seq;
595
+ this.seq = this.seq >= MAX_SEQ ? 0 : this.seq + 1;
596
+ return value;
597
+ }
598
+ startHeartbeatTimer() {
599
+ if (this.heartbeatTimer !== undefined || this.heartbeatIntervalMs <= 0) {
600
+ return;
601
+ }
602
+ const timer = setInterval(() => this.heartbeat(), this.heartbeatIntervalMs);
603
+ if (typeof timer.unref === "function") {
604
+ timer.unref();
605
+ }
606
+ this.heartbeatTimer = timer;
607
+ }
608
+ stopHeartbeatTimer() {
609
+ if (this.heartbeatTimer !== undefined) {
610
+ clearInterval(this.heartbeatTimer);
611
+ this.heartbeatTimer = undefined;
612
+ }
613
+ }
614
+ rejectPendingServe(error) {
615
+ if (this.pendingServe !== undefined) {
616
+ const pending = this.pendingServe;
617
+ this.pendingServe = undefined;
618
+ pending.reject(error);
619
+ }
620
+ }
621
+ /**
622
+ * Fan a value out to a set of subscribers, isolating each one's failures.
623
+ *
624
+ * A subscriber that throws must never break dispatch to the remaining
625
+ * subscribers, and — critically — must never propagate out of internal
626
+ * plumbing that emits events. `emitError` in particular runs inside error
627
+ * handling (e.g. a malformed inbound frame), so an `onError` subscriber that
628
+ * throws would otherwise escape that handler and can take the worker down.
629
+ * Containing throws here is the single canonical dispatch contract for every
630
+ * `emit*` below, so no individual emitter can reintroduce that failure mode.
631
+ */
632
+ dispatch(listeners, value) {
633
+ for (const listener of listeners) {
634
+ try {
635
+ listener(value);
636
+ }
637
+ catch {
638
+ // A subscriber's failure is its own problem; contain it so event
639
+ // reporting can neither break sibling subscribers nor crash internal
640
+ // handling. We deliberately do not re-emit onError here — an onError
641
+ // subscriber that throws must not trigger unbounded re-entry.
642
+ }
643
+ }
644
+ }
645
+ emitServe(value) {
646
+ this.dispatch(this.serveListeners, value);
647
+ }
648
+ emitFrame(value) {
649
+ this.dispatch(this.frameListeners, value);
650
+ }
651
+ emitOpen() {
652
+ this.dispatch(this.openListeners, undefined);
653
+ }
654
+ emitClose(value) {
655
+ this.dispatch(this.closeListeners, value);
656
+ }
657
+ emitError(value) {
658
+ this.dispatch(this.errorListeners, value);
659
+ }
660
+ emitDrain() {
661
+ this.dispatch(this.drainListeners, undefined);
662
+ }
663
+ }
664
+ /**
665
+ * Connect a worker to the Nano agentic channel and return the client. The
666
+ * transport begins connecting immediately; because everything the worker
667
+ * produces is buffered, callers may `register`/`relay` straight away even before
668
+ * the channel is open (invariant #6).
669
+ */
670
+ export function connectAgenticChannel(options) {
671
+ const client = new AgenticClient(options);
672
+ client.connect();
673
+ return client;
674
+ }
675
+ const utf8Encoder = new TextEncoder();
676
+ // UTF-8 byte length of `text`. Prefer Node's `Buffer.byteLength`, which computes
677
+ // the length without allocating, on the `relay()` hot path where the extra
678
+ // per-call `Uint8Array` allocation from `TextEncoder.encode()` would add
679
+ // measurable GC/CPU overhead during bulk output storms. Fall back to
680
+ // `TextEncoder` where `Buffer` is unavailable (non-Node hosts).
681
+ function byteLength(text) {
682
+ if (typeof Buffer !== "undefined") {
683
+ return Buffer.byteLength(text, "utf8");
684
+ }
685
+ return utf8Encoder.encode(text).length;
686
+ }
687
+ function isServePayload(payload) {
688
+ if (typeof payload !== "object" || payload === null) {
689
+ return false;
690
+ }
691
+ if (!("instance" in payload) || !("tokens" in payload)) {
692
+ return false;
693
+ }
694
+ const { instance, tokens } = payload;
695
+ return (typeof instance === "string" &&
696
+ Array.isArray(tokens) &&
697
+ tokens.every((token) => typeof token === "string"));
698
+ }
699
+ function asError(error, fallback) {
700
+ if (error instanceof Error) {
701
+ return error;
702
+ }
703
+ return new Error(fallback);
704
+ }
705
+ /** Re-exported so `isMessageFamily` is available to callers narrowing frames. */
706
+ export { isMessageFamily };