import { type WSClientMessage, type WSWelcome, type WSMessageHandler, type WSErrorHandler } from "./client"; import { WSClientError } from "./errors"; /** Reasons the reconnect facade can transition to terminal "closed" state. * ReconnectCloseInfo.kind and ReconnectStoppedError.kind share this union — * 5 values used consistently across onClose handler, give-up event, and * cause chain (rev 2 Codex C4). */ export type ReconnectStopReason = "caller-close" | "signal" | "fatal-close" | "fatal-error" | "max-attempts"; /** Inline error class — reconnect's own final-state error. Lives at the top * of reconnect.ts, NOT in wsclient/errors.ts (scope fence #1). * rev 2 Codex C4: renamed from ReconnectAbortedError to ReconnectStoppedError. */ export declare class ReconnectStoppedError extends Error { readonly name = "ReconnectStoppedError"; readonly kind: ReconnectStopReason; readonly cause: WSClientError | undefined; constructor(kind: ReconnectStopReason, cause: WSClientError | undefined, message: string); } /** Terminal-close info passed to facade.onClose handler (rev 2 Codex C3). * NOT a re-use of M1-06 WSCloseInfo — facade close semantics are wider than * a single WS close frame. **No wasClean field** (M1-06 WSCloseInfo also * doesn't have one; rev 1 wrote it incorrectly, rev 2 removed). */ export interface ReconnectCloseInfo { readonly kind: ReconnectStopReason; readonly code?: number; readonly closeReason?: string; readonly cause?: WSClientError | ReconnectStoppedError; } export type ReconnectCloseHandler = (info: ReconnectCloseInfo) => void; /** Backoff jitter strategy. Default "full" (rev 2 Roy 拍板 #2). */ export type JitterStrategy = "none" | "full" | "equal"; /** Why the facade is sitting in "parked" (seat lost to another connection). */ export type ParkedReason = "seat-taken" | "superseded-self"; /** Result of one ask-before-dial seat probe (redesign P3). `connected` is * whether ANY connection currently holds this agent's seat server-side; * `instanceMatches` is whether that holder reported OUR process instance id * (i.e. it is a zombie of this very process — reclaiming it is correct). */ export interface SeatProbeResult { readonly connected: boolean; readonly instanceMatches: boolean; } /** Live snapshot of the facade's state machine (redesign P4). The UI derives * its phase from THIS — never from narrating the event stream, which is what * wedged the desktop pill on "connecting" while actually online. */ export interface ReconnectStateSnapshot { readonly state: "connecting" | "connected" | "backoff" | "parked" | "suspended" | "closed"; /** Attempt counter of the CURRENT disconnect cycle (resets on success). */ readonly attempt: number; /** Cumulative attempts over this facade's lifetime — never resets. This is * the number the desktop host historically surfaced as「重连次数」(审查 * F10: facade.attempt resets on success, so it alone cannot feed that UI). */ readonly totalAttempts: number; /** Wall-clock ms of the next scheduled dial, null when not in backoff. */ readonly nextRetryAt: number | null; /** Wall-clock ms the CURRENT session connected, null when not connected. */ readonly connectedAt: number | null; readonly welcome: WSWelcome | null; readonly parkedReason: ParkedReason | null; /** Consecutive auth-class dial failures (handshake 401/404 — revoked key, * deleted agent, or a server whose auth isn't up yet). Resets on success, * on a non-auth failure, and on a successful refreshApiKey (new credential * = new story). Lets the UI escalate「连接中…」into an actionable * credential warning without the loop ever giving up (连接审计 #5). */ readonly authFailures: number; /** Monotonic per-facade sequence. Consumers must drop snapshots whose seq is * ≤ the last applied one (IPC reorder/stale-pull guard, 审查 F6). */ readonly seq: number; } export type ReconnectStateHandler = (snap: ReconnectStateSnapshot) => void; export interface ReconnectingWSClientOptions { url: string; apiKey: string; /** Per-device id sent as X-Device-Id (single-device binding / anti-theft). */ deviceId?: string; /** Which program is running this agent — see WSClientOptions.clientKind. */ clientKind?: string; /** This build's version — see WSClientOptions.bridgeVersion. */ bridgeVersion?: string; /** Capability tokens for the X-AIFight-Capabilities handshake header — * see WSClientOptions.capabilities. Forwarded verbatim on every dial. */ capabilities?: readonly string[]; /** Override the process instance id (tests simulating two processes only). */ instanceId?: string; expectedProtocolVersion: string; initialBackoffMs?: number; backoffFactor?: number; maxBackoffMs?: number; jitter?: JitterStrategy; /** Default: undefined → no cap (Roy 拍板 #3). Caller controls termination * via signal + AbortController.abort(timeoutMs). * * Counts CONSECUTIVE failures. Since 2026-07-24 a connect that succeeds but * dies again within `stabilityWindowMs` counts as a continuation rather than * a reset, so a link that flaps fast enough can now exhaust this cap even * though every attempt technically connected. The bridge sets no cap — it * must never give up — so this only affects direct callers of the facade. */ maxAttempts?: number; welcomeTimeoutMs?: number; pingIntervalMs?: number; /** Passed through to each inner WSClient — see WSClientOptions.livenessTimeoutMs. */ livenessTimeoutMs?: number; /** How long a session must survive after welcome before its eventual close * counts as a fresh disconnect cycle (backoff restarts at 1s). Sessions * shorter than this are treated as flaps and keep escalating the existing * curve. Default DEFAULT_STABILITY_WINDOW_MS (30s). */ stabilityWindowMs?: number; /** Parked-state probe cadence (redesign P3). Defaults 5min + up to 1min * jitter. Exposed for tests. */ parkedProbeIntervalMs?: number; parkedProbeJitterMs?: number; signal?: AbortSignal; /** Ask-before-dial seat probe (redesign P3, 审查 F4). Called while parked, * before every re-dial. Return null (or throw) when the probe endpoint is * unavailable — the facade then falls back to dialing blind, which matches * the pre-redesign behaviour against old servers. When it answers: the * facade dials only if the seat is empty or held by OUR OWN process * (reclaiming a zombie of ourselves); a seat held by someone else keeps us * parked so we never rip an active connection out of a live match. */ probeSeat?: () => Promise; /** R13-F08: called after a reconnect attempt failed with a 401 handshake, so * a credential rotated out from under this process (e.g. re-pairing rewrote * the bridge config while it kept running) is picked up without a restart. * Returning a non-empty key different from the one in use swaps the * credential and restarts the backoff curve (fresh credential = fresh * cycle, so the next attempt comes quickly). Errors and empty/null returns * keep the cached key. Never called on first-connect 401 (still terminal) * or for non-auth failures. */ refreshApiKey?: () => Promise | string | null | undefined; } export interface ReconnectEvent { readonly type: "attempt-start" | "attempt-success" | "attempt-failure" | "parked" | "superseded-self" | "give-up"; readonly attempt: number; readonly nextDelayMs?: number; readonly cause?: WSClientError | ReconnectStoppedError; readonly elapsedMs: number; readonly severity: "info" | "warning" | "error"; } export type ReconnectEventHandler = (ev: ReconnectEvent) => void; /** Stable facade — caller holds this reference indefinitely. Inner WSClient * is mutable across reconnects; facade type is stable. */ export interface ReconnectingWSClient { readonly state: ReconnectStateSnapshot["state"]; readonly attempt: number; readonly totalAttempts: number; readonly welcome: WSWelcome | null; readonly nextRetryAt: number | null; readonly connectedAtMs: number | null; readonly parkedReason: ParkedReason | null; send(msg: WSClientMessage): void; onMessage(handler: WSMessageHandler): () => void; onError(handler: WSErrorHandler): () => void; onClose(handler: ReconnectCloseHandler): () => void; onReconnect(handler: ReconnectEventHandler): () => void; /** State-machine projection (redesign P4). Fires on every state edge — * including connected, which the legacy event stream never surfaced. The * handler is also invoked once immediately with the current snapshot so a * late subscriber cannot miss the standing state. */ onStateChange(handler: ReconnectStateHandler): () => void; /** Snapshot getter (pull counterpart of onStateChange, for IPC bootstrap). */ snapshot(): ReconnectStateSnapshot; /** Wake the loop NOW (redesign P2): in backoff → dial immediately; parked → * probe immediately; suspended → resume with a fresh curve and dial. No-op * while connected/connecting/closed. */ poke(): void; /** Enter the non-terminal suspended state (redesign P5): gracefully close * the inner socket (wire-level 1000 "host sleeping" — the server frees the * seat instantly instead of holding a zombie until its read deadline), stop * scheduling retries, keep the facade alive. poke() resumes. NOT close(): * close() stays terminal (审查 F12 — a literal close() per lid-close would * tear the bridge down and race the seat lock). */ suspend(): void; close(code?: number, reason?: string): Promise; } /** * Open a reconnecting WebSocket session. Returns a Promise that: * * - **resolves** on the FIRST inner WSClient connect+welcome success * - **rejects** with ReconnectStoppedError on fatal first failure (signal * pre-aborted / WSHandshakeError 401|403|404 / WSWelcomeInvalidError / * WSProtocolVersionError / WSAbortedError / max-attempts during the * first-connect retry chain) * - **stays pending** while transient first failures (WSConnectError / * WSWelcomeTimeoutError / WSHandshakeError 408|429|5xx) drive backoff * and re-attempt, until a success or fatal terminator * * After the Promise resolves, the returned facade survives across server * disconnects: inner WSClient close → backoff → new createWSClient → * handlers re-wired. Caller's onMessage / onError / onClose / onReconnect * handlers persist across reconnects automatically. */ export declare function createReconnectingWSClient(opts: ReconnectingWSClientOptions): Promise;