/** * RelayClient — outbound persistent WebSocket from this daemon to the * Loopsy relay. Mobile clients connect to the relay over the public internet, * and the relay splices their session WebSockets into ours. * * Wire protocol mirrors what the relay's DeviceObject expects: * - text frames : JSON control with `sessionId` field * - binary frames: [16-byte session UUID][PTY bytes] * * Lifecycle: * start() → connect → reconnect on close with exponential backoff * stop() → close cleanly, no further reconnects * * Translates incoming control + data frames to PtySessionManager calls, * and forwards PTY output back to the relay tagged by sessionId. */ import type { CustomCommand, RelayConfig } from '@loopsy/protocol'; import { PtySessionManager } from './pty-session-manager.js'; export interface RelayClientLogger { info(msg: string, ctx?: Record): void; warn(msg: string, ctx?: Record): void; error(msg: string, ctx?: Record): void; } export interface RelayClientConfig { relay: RelayConfig; pty: PtySessionManager; logger?: RelayClientLogger; /** * The current custom-command list and a setter that persists changes * back to ~/.loopsy/config.yaml. Daemon-side ownership of this list is * what lets every paired phone (and the web client) see the same * shortcuts. RelayClient mutates the list in response to phone * control frames and broadcasts the new list to all listeners. */ customCommands?: CustomCommand[]; saveCustomCommands?: (commands: CustomCommand[]) => Promise; } export interface RelayClientStatus { /** True iff the underlying WebSocket is OPEN and we haven't been stopped. */ connected: boolean; /** Configured URL we're connecting to (or were last connected to). */ url: string; /** Last connect/socket error surfaced from the WebSocket; null after a successful reconnect. */ lastError: string | null; } export declare class RelayClient { private cfg; private pty; private log; private ws; private stopped; private reconnectMs; private reconnectTimer?; /** Guards a single CONNECTING attempt from hanging forever (see CONNECT_TIMEOUT_MS). */ private connectWatchdog?; private heartbeatTimer?; /** * Liveness flag for the heartbeat. Set false right after we send a ping, * back to true when the matching pong arrives. If it's still false at the * next heartbeat tick the peer never answered, so we treat the * OPEN-but-dead socket as gone and terminate it to force a reconnect — * covers a NAT/firewall silently dropping the flow with no TCP FIN. */ private pongReceived; /** sessionId → handle so we can tear down listeners on disconnect. */ private sessions; /** * sessionId → live ChatEventStream. Chat is piggy-backed on an attached * PTY session — the phone subscribes by the same sessionId it's using * for the terminal view, and the daemon resolves the cwd from the PTY. * Capped at one chat stream per session in v1 (the relay only ever has * one client per sessionId anyway). */ private chats; /** Daemon-side custom command list. Mutated in-place and persisted. */ private customCommands; private saveCustomCommands; /** Last error observed on the socket; cleared on successful (re)connect. */ private lastError; constructor(cfg: RelayClientConfig); /** * Snapshot of the relay link state. Used by /api/v1/relay/status so the * CLI can poll until a freshly-reconfigured RelayClient has actually * established its WebSocket before issuing a pair QR. */ getStatus(): RelayClientStatus; start(): void; stop(): void; private connect; private scheduleReconnect; private clearConnectWatchdog; private startHeartbeat; private stopHeartbeat; private handleBinary; private handleText; /** * Phone requested a new session (or first re-open after detach with a fresh * agent choice). Spawn the PTY and attach a listener that forwards data * back to the relay tagged with the session UUID. * * If a session already exists for this id, reuse it (idempotent). */ private handleSessionOpen; private handleSessionAttach; /** * Build the CLI argv prefix needed to resume a prior session for this * agent, if any. Falls back to `[]` (fresh session) when: * - This is the first time we've seen the loopsy sessionId * - The agent doesn't expose a resume mechanism * - The stored session-id refers to a file that's been deleted * * The actual mapping is owned by ClaudeSessionTracker for now since * Claude is the only agent we know how to resume via a flag. Codex's * `codex resume` sub-command pattern needs different plumbing — track * but don't act yet. */ private resumeArgsForAgent; private handleSessionDetach; /** * Start tailing the Claude JSONL for `sessionId` and forward each * translated ChatEvent back over the relay tagged with the same * sessionId. The phone uses the same routing key for terminal and chat * frames — that keeps the relay's session-routing logic untouched. * * If a chat stream is already running for this session, replace it so * a reconnecting client can pass a fresh `fromOffset` for partial * replay. */ private handleChatSubscribe; private stopChatStream; /** * Tell the phone what host this daemon is running on + which agents are * actually installed. The phone hides unavailable agents from the * picker and skips the macOS-password auto-approve flow on non-darwin * hosts (where dscl-based verification can't run anyway). */ private sendDeviceInfo; /** * Reply with the latest custom-command list. The relay only forwards * device→phone JSON when a sessionId is present, so we always echo the * sessionId of whichever phone session triggered the mutation. */ private broadcastCustomCommands; /** * Mutate the daemon-side custom-command list in response to a phone * control frame and persist the result. Returns the updated list so we * can include it in any response, alongside the broadcast. */ private applyCustomCommandMutation; private sendBinary; private sendText; } //# sourceMappingURL=relay-client.d.ts.map