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