/** * The client-side ownership/presence EMIT client — the emit-side counterpart of * the S2/ownership protocol keystone (#543 shipped the *definitions*; this ships * the blessed client that *sends* them). * * One {@link AgenticEmitClient} owns ONE multiplexed host connection that N * distinct instances share, and emits every worker→hub ownership/presence frame * — `register` / `heartbeat` / `deregister` / `claim` / `release` and the * `relay` transcript sink — tagging the OWNING `instance` EXPLICITLY in each * frame (never `conn.id`). That explicit tag is what lets a single supervisor * connection multiplex the ownership frames of many workers; it is the emit * capability the low-level `protocol/frame` + `protocol/payloads` primitives and * the server-side `channel` registry never provided. * * Three invariants make it the single source of truth a supervisor can build a * concrete `AgenticEndpoint` on without hand-rolling a parallel client layer: * * - **Multiplexed presence.** register/heartbeat/deregister and claim/release * all carry `instance` per frame, so one socket carries the whole fleet. * - **Reconnect resync.** On every (re)connect the client re-`register`s all * known instances and re-`claim`s all in-flight jobs BEFORE it fires * {@link AgenticEmitClientOptions.onOpen} — the hook where the caller resumes * transcript. claim/release are idempotent (a duplicate `{instance, jobKey}` * is a no-op re-assertion), so re-asserting on reconnect is always safe. * - **Additive / version-negotiated.** The client negotiates against the peer's * advertisement ({@link negotiate}); a family the far end can't decode (e.g. a * legacy hub with no `claim`/`release`) degrades that emit to a silent no-op * instead of putting an `unknown-family` frame on the wire. * * Transport- and timer-injected exactly like the cockpit relay client: the * socket comes from an {@link EmitSocketFactory} and reconnect scheduling from an * injected {@link Scheduler}, so the whole register→claim→reconnect→resync path * is exercised deterministically over an in-memory socket — no real timers, no * real network, no test retries. */ import { type Capability, type NegotiatedProtocol, type ProtocolAdvertisement } from "../protocol/index.ts"; /** * The minimal duplex socket the emit client drives. A browser `WebSocket`, a * `ws` socket, or an in-memory test double all adapt to this — the client never * depends on a concrete transport. */ export interface EmitSocket { /** Send one already-encoded frame as binary bytes. */ send(bytes: Uint8Array): void; /** Close the socket. */ close(): void; /** Register the inbound-binary-frame listener. */ onMessage(listener: (bytes: Uint8Array) => void): void; /** Register the open listener (fired once per successful connect). */ onOpen(listener: () => void): void; /** Register the close listener (fired once when the socket drops). */ onClose(listener: () => void): void; } /** Opens a fresh socket. Called once per (re)connect. */ export type EmitSocketFactory = () => EmitSocket; /** Schedules a reconnect attempt. Injected so tests can run it synchronously. */ export type Scheduler = (run: () => void) => void; /** * Identifies one transcript/relay stream: an `instance` and its named `stream`. * * **Transcript convention.** For a *job transcript* — the one relay stream that * carries a running job's terminal output — the stream name is the job key as a * string: `stream = String(jobKey)`. A job transcript stream id is therefore the * pair `(instance, jobKey)`, composed with {@link composeStreamId} on the write * side and decoded with {@link parseStreamId} on the read side. This is the one * place that "a transcript stream id is `(instance, jobKey)`" fact is defined; * both the producer (routing inbound steer back to `{instance, jobKey}`) and the * consumer (attributing a stored stream to a job) derive it from here. */ export interface TranscriptRef { /** The owning instance the stream belongs to. */ readonly instance: string; /** * The instance-local stream name. For a job transcript this is the job key * stringified — `stream = String(jobKey)` (see {@link TranscriptRef}). */ readonly stream: string; } export interface AgenticEmitClientOptions { /** Opens a fresh socket for each (re)connect. */ readonly connect: EmitSocketFactory; /** * The peer's protocol advertisement, if known at construction (e.g. captured * at the handshake). Negotiation runs against it so an emit for a family the * far end can't decode degrades to a no-op. When omitted, the client assumes a * peer that supports everything this build does (full support) until * {@link AgenticEmitClient.setPeerAdvertisement} is called. May be a raw, * untrusted value off the wire — it is parsed defensively. */ readonly peerAdvertisement?: ProtocolAdvertisement | unknown; /** This build's own advertisement. Defaults to {@link LOCAL_ADVERTISEMENT}. */ readonly localAdvertisement?: ProtocolAdvertisement; /** Fired on every (re)connect AFTER resync — the caller resumes transcript here. */ readonly onOpen?: () => void; /** Fired when the socket drops (before a reconnect is scheduled). */ readonly onClose?: () => void; /** Notified of a send/encode error. A bad frame never wedges the client. */ readonly onError?: (err: unknown) => void; /** Reconnect scheduler. Default `setTimeout(run, 0)`. */ readonly schedule?: Scheduler; /** Reconnect automatically on close. Default true. */ readonly autoReconnect?: boolean; } /** * Compose the per-instance relay stream id for a {@link TranscriptRef}. The * length-prefix makes the encoding injective: no two distinct `{instance, * stream}` pairs can ever map to the same id (so two instances' streams never * cross), regardless of what delimiter characters an instance or stream name * contains. This is the isolation guarantee the transcript sink rests on. * * The prefix `N` is `instance.length`, i.e. the count of JS **UTF-16 code * units** — not Unicode code points and not UTF-8 bytes. A cross-language * consumer/producer MUST measure and slice the instance in UTF-16 code units to * stay in sync (e.g. an astral character like an emoji counts as 2), otherwise * the length will mismatch. * * For a job transcript, the stream name is the job key stringified — `stream = * String(jobKey)` — so the composed id encodes the `(instance, jobKey)` pair * (see {@link TranscriptRef}). {@link parseStreamId} is the exact inverse. */ export declare function composeStreamId(instance: string, stream: string): string; /** * The exact inverse of {@link composeStreamId}: decode a `N:/` * length-prefixed relay stream id back into its {@link TranscriptRef}. Reads the * decimal length `N` up to the first `:`, takes the next `N` UTF-16 code units * (JS `String` indices, matching the `instance.length` the composer emits) as * the `instance` (so a `:` or `/` inside the instance name is decoded * losslessly), requires the following `/`, and treats the remainder as the * `stream`. * * Returns `undefined` for any malformed id — a missing/non-decimal or * non-canonical length prefix (e.g. a leading zero the composer never emits), a * length that overruns the string, or a missing `/` delimiter after the * instance — so a bad id off the wire can never be mistaken for a valid ref. For * a job transcript the decoded `stream` is `String(jobKey)` (see * {@link TranscriptRef}). */ export declare function parseStreamId(id: string): TranscriptRef | undefined; /** * A first-class client that owns ONE multiplexed connection and emits the * ownership/presence/transcript frames of N instances over it, each tagging its * `instance` explicitly. Construct once per supervisor connection; drive its * lifecycle with {@link open}/{@link close} and emit through the per-instance * methods. */ export declare class AgenticEmitClient { #private; constructor(options: AgenticEmitClientOptions); /** True once {@link close} has been called (no further reconnects). */ get isClosed(): boolean; /** The protocol negotiated with the peer — the families/features safe to emit. */ get protocol(): NegotiatedProtocol; /** Instances currently known to the client (registered, not yet deregistered). */ get instances(): readonly string[]; /** The in-flight (claimed, un-released) job keys for an instance. */ inFlight(instance: string): readonly string[]; /** * Update the peer advertisement and re-derive the negotiated protocol. Call * this when a fresh handshake reveals a different peer (e.g. after a reconnect * to a hub that has since been upgraded). Accepts a raw, untrusted value. */ setPeerAdvertisement(advertisement: ProtocolAdvertisement | unknown): void; /** Open the first socket and wire its lifecycle. Idempotent while connected. */ open(): void; /** Enrol `instance` with its capability. Tracked for reconnect re-registration. */ register(instance: string, capability: Capability): void; /** Refresh `instance` liveness. */ heartbeat(instance: string): void; /** * Withdraw `instance`. Drops it (and any of its in-flight jobs) from the * tracked set so a later reconnect does not resurrect a departed worker. */ deregister(instance: string, reason?: string): void; /** * `instance` now OWNS `jobKey`. Idempotent: a duplicate claim for the same * `{instance, jobKey}` re-asserts ownership and is a no-op on the hub. Tracked * as in-flight so a reconnect re-claims it. A no-op if the negotiated peer * can't decode the `claim` family (legacy degradation). */ claim(instance: string, jobKey: string): void; /** * `instance` has RELEASED `jobKey`. Idempotent: a late/duplicate release — even * with no preceding claim — is a no-op. Stops tracking the job so a reconnect * does not re-claim a finished job. A no-op if the negotiated peer can't decode * the `release` family (legacy degradation). */ release(instance: string, jobKey: string): void; /** * Append a transcript `chunk` to a `{instance, stream}` relay stream. The * stream id is composed per-instance ({@link composeStreamId}) so two * instances' transcripts can never cross. Rides the `bulk` lane and stamps the * current producer {@link #generation} as the `incarnation`, so a resumed * producer fences its stale predecessor. Emits nothing if not yet connected. */ transcript(ref: TranscriptRef, chunk: string): void; /** Close for good — no reconnect will follow. */ close(): void; }