import { Agent, AgentChatOptions, AgentReply, AgentRunner } from "./agent"; import { AnyHook } from "./hook"; import { LinkClientFrameType, LinkClientPayloads, LinkScopeKind, LinkServerFrame } from "./protocol"; import { SocketFactory } from "./socket"; import { AnyTool } from "./tool"; import { type LinkSubscription } from "./subscriptions"; export type LinkState = "idle" | "connecting" | "open" | "closed"; export type LinkEvents = { /** The link is connected and everything it holds has been registered. */ connect: [{ connectionId: string; scope: LinkScopeKind; }]; disconnect: [{ code: number; reason: string; willReconnect: boolean; }]; /** A protocol or transport error. Fatal ones also stop reconnection. */ error: [Error]; /** Server-side chatter, useful when debugging. */ log: [string]; /** The server is shutting this connection down deliberately. */ goodbye: [{ reason: string; reconnectAfterMs: number; }]; /** * The set of things the server wants watched has changed. * * Fires on every snapshot and every delta, including the first one after connecting. Use it to * set up whatever your platform needs in order to watch — a channel listener, a poll — and note * that it can fire with an empty list, which means "nothing is subscribed right now". */ subscriptions: [LinkSubscription[]]; }; export type LinkOptions = { /** A user API key, or the service key for a global link. */ apiKey: string; /** * This link's name, chosen by you and stable forever. * * Every id the link creates is derived from it, and those ids are what the * user's tool settings and background agent subscriptions point at — so changing * it silently orphans both. Pick a deliberate constant ("coffee-machine"), never * a hostname, a version, or a value generated at startup. * * Two live connections claiming the same linkId is last-writer-wins: the newer * one takes over and the older one's registrations are released. */ linkId: string; /** The link service. Defaults to the hosted one, which is not the core server. */ serverUrl?: string; /** Informational, shown in server logs. Defaults to the SDK name. */ client?: string; debug?: boolean; /** Reconnect automatically with backoff. Default true. */ reconnect?: boolean; minReconnectDelayMs?: number; maxReconnectDelayMs?: number; /** * How long the connection may be silent before it is pinged, which both proves it is * alive and keeps proxies from dropping it. A connection that is receiving frames is * never pinged. 0 disables. Default 30s. */ heartbeatMs?: number; /** How long to wait for an acknowledgement. Turns are never timed out here. */ requestTimeoutMs?: number; socketFactory?: SocketFactory; }; export type ExchangeOptions = { /** Which reply ends the exchange. Defaults to an `ack`. */ isDone?(frame: LinkServerFrame): boolean; /** Called for every other reply, in order. */ onFrame?(frame: LinkServerFrame): void; /** 0 waits forever, which is what a conversation turn needs. */ timeoutMs?: number; /** Set false only for the handshake itself. */ awaitReady?: boolean; }; /** * A live connection to Alfred that carries tools, hooks and conversations. * * The server keeps no record of a link between connections: everything is * re-declared on connect, and ids are derived from your `linkId`, so a reconnect * anywhere lands on the same saved settings and subscriptions. */ export declare class Link implements AgentRunner { private readonly options; private readonly emitter; private readonly tools; private readonly agents; private readonly hooks; private readonly subscriptionStore; private readonly pending; private readonly calls; private socket; /** Every socket this link has opened and not yet closed, by generation. */ private readonly sockets; private frameCounter; private currentState; private identity?; /** * Which socket the callbacks below belong to. * * Every handler carries the generation it was made for and does nothing once that is * no longer current. Without it a late `close` from a socket we have already replaced * tears down its successor — one blip turning into a link that flaps for the life of * the process. */ private generation; /** True from the moment a socket is created until it is open or gone. */ private attempting; private reconnectTimer?; /** Settled per attempt: this is what `connect()` awaits. */ private readonly attemptWaiters; /** Settled when the link opens, however many attempts that takes. `ready()` awaits these. */ private readonly openWaiters; private reconnectAttempt; private reconnectAfterMs; private heartbeat?; /** * When the server last said anything at all. * * Any frame is proof the connection is alive, so a link that is busy carrying a turn * never needs to ask. */ private lastInboundAt; /** Ids of keepalive pulses, so their pongs can be dropped instead of shown as logs. */ private pulses; private closedByUs; constructor(options: LinkOptions); /** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */ addTool(tool: AnyTool): this; /** * Adds an agent, with the tools it works with. * * The tools come with the agent — they need no `addTool` of their own, and giving them one * would place them in the chat as well, which is the thing an agent exists to avoid. One * namespace for everything the link declares, so an id used twice is refused here rather * than resolved by whichever registered last. */ addAgent(agent: Agent): this; /** Adds a hook that can wake the user's background agents. */ addHook(hook: AnyHook): this; getTool(id: string): AnyTool | undefined; getHook(id: string): AnyHook | undefined; getAgent(id: string): Agent | undefined; /** A tool by its local id, wherever it lives: on the link itself or behind one of its agents. */ private findTool; get state(): LinkState; get linkId(): string; /** The ephemeral id of this connection. Changes on every reconnect. */ get connectionId(): string | undefined; /** Whether this link speaks for one user or for the whole service. */ get scope(): LinkScopeKind | undefined; on(event: K, listener: (...args: LinkEvents[K]) => unknown): string; off(event: K, id: string): void; /** * Connects, resolving once every tool and hook has been registered. * * Rejects if *this* attempt fails. When `reconnect` is on the link keeps trying in * the background regardless, so a caller can either await this again or just listen * for the `connect` event. */ connect(): Promise; /** * Resolves when the link is usable, connecting first if it has not been asked to yet. * * Bounded by `requestTimeoutMs`, and it never opens a socket of its own while one is * in flight: the callers are hook emits and tool replies, which are worth sending now * or not at all. Waiting out a long outage here used to mean a new connection per * event. */ ready(): Promise; /** Closes for good. Registrations are released server-side as the socket drops. */ close(reason?: string): void; /** * Starts an attempt, but only when there is nothing to join. * * The single door to opening a socket: an attempt in flight, or a reconnect already * waiting out its backoff, is the attempt. */ private ensureAttempting; private openSocket; private onOpen; /** * Abandons a socket and treats it as closed right now. * * A connection that died without a close frame can take minutes to report it, or * never, so the close is synthesised rather than waited for. Bumping the generation * means the real event, whenever it turns up, is ignored. */ private dropSocket; /** * Closes a socket this end has stopped using, whatever generation it belongs to. * * Every path that walks away from a socket goes through here. Forgetting one is not harmless: * the server has no way to tell an abandoned connection from a live one — it has said hello, * registered its tools and claimed its link id — so it keeps it, keeps serving from it, and * hands the link id back and forth between it and its replacements. */ private abandon; private onClose; /** Retries when it is allowed to, and tells everyone waiting when it is not. */ private retryOrGiveUp; /** * Reconnects with full jitter on top of any delay the server asked for. * * A fleet told to reconnect must not come back in unison, which is exactly what * a fixed delay produces. */ private scheduleReconnect; private clearReconnect; /** * Pings a SILENT connection and, the important half, notices when a ping goes unanswered. * * A websocket can die without a close frame — a dropped route, a proxy that forgets * the connection, a suspended machine — leaving both ends convinced they are * connected while every frame sent into it vanishes. An unanswered ping is the only * evidence this end will ever get, so it is treated as a dead connection and * reconnected rather than swallowed. * * It only asks when nothing has arrived for a whole interval, because a connection * that is delivering frames has already answered the question. Pinging regardless * meant a busy link had to complete a round trip while the socket was carrying a * streaming turn: the reply queues behind everything already in flight, and a turn * big enough to take longer than the timeout to drain got its own connection torn * down with `4000 heartbeat timeout` — always mid-response, always on the longest * answers, which are the ones a user least wants to lose. * * Not asking is not the same as saying nothing, though. The server reaps connections * that have sent it no frames for 100s, because a socket the client walked away from * still answers websocket pings at the network layer and only the client's own frames * prove someone is still there. A link busy receiving a long turn used to go completely * silent for as long as the turn ran and got closed as idle — `1001 idle: no frames * received`, mid-response again. So a busy interval still sends a pulse; it just does * not wait for the reply, which is the half that could not survive a full send queue. */ private startHeartbeat; /** Wakes when the connection will have been silent for a full interval, not before. */ private scheduleHeartbeat; /** One liveness round trip. Only ever sent to a connection that has gone quiet. */ private ping; /** * A ping sent with no deadline and no interest in the answer. * * Its only job is to land on the server so the connection does not look abandoned. A * failure here is not evidence of anything — inbound frames already proved the socket * works — so it stays quiet and lets the real heartbeat make that call. */ private pulse; /** Never longer than the interval itself: a second ping in flight tells us nothing new. */ private heartbeatTimeoutMs; /** Any frame from the server, of any kind, is proof the connection still works. */ private markInbound; private stopHeartbeat; /** Parks a caller until someone settles the list it was parked in. 0 waits forever. */ private wait; private settleWaiters; private registerAll; private registerAgents; /** * Called by `Agent.chat`. * * One exchange, however long the agent takes: status frames go to the caller as they * arrive, and the result frame ends it. Never timed out here — an agent that is calling * tools on this very machine may legitimately take a while. */ chatAgent(agentId: string, message: string, options: AgentChatOptions): Promise; private registerTools; private registerHook; /** * Called by `Hook.report`. * * Sends nothing when the event matched nothing, which is the entire volume story: a busy channel * produces thousands of events a day that no reflex asked about, and none of them reach the wire. * * The epoch travels with the frame so the server can tell a stale view from a bad one — an id * that was valid a moment ago is a race, not a bug worth complaining about. */ reportHookEvent(hookId: string, event: string, payload?: Record, chosenIds?: string[]): Promise; /** Called by `Hook.subscriptions`. */ hookSubscriptions(hookId: string): LinkSubscription[]; /** Everything this link has been asked to watch, across all of its hooks. */ get subscriptions(): LinkSubscription[]; /** Called by `Hook.emit`. */ emitHook(hookId: string, event: string, payload?: Record, ownerId?: string): Promise; /** Sends a frame without waiting for anything. Returns its id. */ send(type: T, payload: LinkClientPayloads[T], replyTo?: string): string; /** * Sends a frame and waits for the reply that ends it. * * Intermediate replies (a turn's events, a status update) go to `onFrame`, and an * `error` frame rejects — so a caller handles one outcome, not a stream of maybes. */ exchange(type: T, payload: LinkClientPayloads[T], options?: ExchangeOptions): Promise; private settle; private failPending; private onMessage; private handleToolCall; private debug; }