import type { ClientFrame, ServerFrame, HelloFrame, LogEntry, ConfirmResolvedFrame } from '../../protocol.js'; import { type RpcOptions, type RpcError, type ConfirmWaitResult } from './rpc.js'; export type { RpcOptions, RpcError, ConfirmWaitResult }; /** * Thin abstraction over a single paired WebSocket. Consumed by the * registry implementations; runtime-specific adapters (`ws`-lib, * `WebSocketPair`, `Deno.upgradeWebSocket`, `Bun.serve` upgrade) build * one of these and pass it to `registry.register()`. */ export interface PairingConnection { send(frame: ServerFrame): void; onFrame(handler: (f: ClientFrame) => void): void; onClose(handler: () => void): void; close(): void; } /** * A per-call frame subscriber. Return `true` to remove this * subscriber (one-shot), or `false` to keep receiving. The registry * dispatches every inbound `ClientFrame` to every active subscriber * for the given `tid`; subscribers filter by `frame.t` + identifiers * (correlation id, confirm id, state path) to find the one that * belongs to their request. */ export type FrameSubscriber = (frame: ClientFrame) => boolean; /** * Registry of live browser pairings. Pure routing + hello cache — * request-lifecycle state (in-flight RPC promises, confirm waits, * long-polls) lives in the LAP handlers that need it, not here. * * Two implementations ship today: * - `InMemoryPairingRegistry` for long-lived server processes * (Node, Bun, Deno, Deno Deploy). * - A Cloudflare Durable Object implementation (see * `server/cloudflare`) for stateless Worker runtimes. * * Other runtimes can implement this interface the same way; the * contract is intentionally small. */ export interface PairingRegistry { register(tid: string, conn: PairingConnection): void; unregister(tid: string): void; isPaired(tid: string): boolean; getHello(tid: string): HelloFrame | null; /** Send a frame. No-op when the pairing is absent or closed. */ send(tid: string, frame: ServerFrame): void; /** * Subscribe to frames from the paired browser. Returns an * unsubscribe function. A subscriber can remove itself mid-dispatch * by returning `true` from its callback — useful for one-shot * request/response correlation. */ subscribe(tid: string, handler: FrameSubscriber): () => void; /** * Observe the pairing closing (WebSocket drop, `unregister`, etc.). * Handlers registered before close fire; handlers registered after * close fire synchronously. Returns an unsubscribe function. */ onClose(tid: string, handler: () => void): () => void; /** * Read the most recent `n` log entries for a tid (newest first). * Backed by an in-memory ring buffer populated as the registry * sees `log-append` frames; capped per-tid to bound memory across * long-lived sessions. Drained on close. Returns an empty array * for unknown tids. */ getRecentLog(tid: string, n: number): LogEntry[]; /** * Per-tid cap on the recent-log ring buffer — the ceiling * `getRecentLog` clamps to. Exposed so callers that need "everything * the buffer can hold" (e.g. the `/recent-actions` handler pulling the * full buffer before filtering by kind) reference the registry's own * bound instead of hardcoding a literal that could drift. */ readonly recentLogCap: number; /** * Level-triggered confirm-resolution buffer. The browser emits a * `confirm-resolved` frame exactly once; the registry records its * outcome keyed by `confirmId` with a TTL, independently of whether * any subscriber is currently armed. `waitForConfirm` reads this * BEFORE subscribing so an approval arriving in the gap between one * long-poll's subscriber teardown and the next re-arming is not lost. * * Returns the recorded frame if one landed within the TTL window, * else `null`. Idempotent: repeated reads return the same outcome * until it ages out (confirmIds are UUIDs, so no cross-confirm reuse). */ getConfirmOutcome(tid: string, confirmId: string): ConfirmResolvedFrame | null; /** * Send a typed rpc frame and await its matching reply. See * `./rpc.ts::rpc` for the full contract. */ rpc(tid: string, tool: string, args: unknown, opts?: RpcOptions): Promise; /** See `./rpc.ts::waitForConfirm`. Three-way: confirmed | user-cancelled | timeout. */ waitForConfirm(tid: string, confirmId: string, timeoutMs: number): Promise; /** See `./rpc.ts::waitForChange`. */ waitForChange(tid: string, path: string | undefined, timeoutMs: number): Promise<{ status: 'changed' | 'timeout'; stateAfter: unknown; }>; } export declare class InMemoryPairingRegistry implements PairingRegistry { /** @see PairingRegistry.recentLogCap */ readonly recentLogCap = 100; private pairings; private onLogAppend; private readonly closedRetentionMs; private readonly maxClosedSessions; private readonly now; /** * tid → the moment its socket closed. Present ONLY while a session is * closed but its buffers are still retained; `register` clears it and * the sweep consumes it. This map is what #101 was missing: with no * timestamp on the close, `recentLog` had nothing to age out on and a * plain WS drop (tab reload, laptop lid) stranded up to 100 `LogEntry` * objects per session permanently — only `unregister` (revoke, or a * LAP version mismatch) ever freed them. */ private closedAt; /** * Per-tid ring buffer of recent log entries. Populated as the * registry sees `log-append` frames; trimmed to RECENT_LOG_CAP. * The agent reads this via `describe_recent_actions` to introspect * its own activity history with stateDiffs intact. */ private recentLog; /** * Per-tid buffer of the most recent `confirm-resolved` outcome per * confirmId, with arrival timestamps for TTL pruning. Backs the * level-triggered `getConfirmOutcome` fast path in `waitForConfirm`. */ private confirmOutcomes; constructor(opts?: { onLogAppend?: (tid: string, entry: LogEntry) => void; /** * How long a closed session's buffers stay readable, in ms. * Default {@link CLOSED_RETENTION_MS}. `0` drops them the moment * the socket closes: a reconnect then has to rotate its bearer * through `/resume/claim`, and while that reattaches to the SAME * tid, this registry has already let its history go. */ closedRetentionMs?: number; /** Ceiling on concurrently-retained closed sessions. Default 128. */ maxClosedSessions?: number; /** Wall clock in ms; injectable for tests. */ now?: () => number; }); /** * Diagnostics: how many tids currently hold retained buffers (the * recent-log ring, the confirm-outcome table, or both) — live sessions * and closed-but-not-yet-swept ones alike. This is the registry's own * state, and the number the #101 retention bound is asserted against. */ retainedBufferCount(): number; /** * Drop every buffer belonging to `tid`. The single teardown point, so * a new buffer can never be added without a matching release. */ private dropBuffers; /** * Reclaim closed sessions: first everything past the retention window, * then — while still over the count cap — the oldest-closed. Runs at * every point that adds a closed session (`handleClose`), revives one * (`register`), or reads a buffer, so a lapsed buffer can never be * served and the retained set stays bounded without a timer holding * the process open. */ private sweepClosed; /** * Read the most recent `n` log entries for a tid, newest-first. Returns * an empty array when the tid is unknown or has no recorded activity. * Drained from the in-memory ring buffer; entries older than * RECENT_LOG_CAP have already been trimmed. */ getRecentLog(tid: string, n: number): LogEntry[]; /** @see PairingRegistry.getConfirmOutcome */ getConfirmOutcome(tid: string, confirmId: string): ConfirmResolvedFrame | null; /** * Record a `confirm-resolved` outcome for level-triggered pickup and * opportunistically prune expired entries for this tid so an abandoned * confirm can't pin memory past its TTL. */ private recordConfirmOutcome; register(tid: string, conn: PairingConnection): void; unregister(tid: string): void; isPaired(tid: string): boolean; getHello(tid: string): HelloFrame | null; send(tid: string, frame: ServerFrame): void; subscribe(tid: string, handler: FrameSubscriber): () => void; onClose(tid: string, handler: () => void): () => void; private dispatch; rpc(tid: string, tool: string, args: unknown, opts?: RpcOptions): Promise; waitForConfirm(tid: string, confirmId: string, timeoutMs: number): Promise; waitForChange(tid: string, path: string | undefined, timeoutMs: number): Promise<{ status: 'changed' | 'timeout'; stateAfter: unknown; }>; /** @deprecated Use `send(tid, frame)` directly; semantics are identical. */ notify(tid: string, frame: ServerFrame): void; private handleClose; } /** * Back-compat alias for the prior class name. New code should use * `InMemoryPairingRegistry`. Removed in a future major. * * @deprecated Use `InMemoryPairingRegistry` directly. */ export declare const WsPairingRegistry: typeof InMemoryPairingRegistry; export type WsPairingRegistry = InMemoryPairingRegistry; //# sourceMappingURL=pairing-registry.d.ts.map