From b6d670deacc1bdd9c634949ffda2d5be28592e5d Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 28 Jan 2026 18:39:04 -0500 Subject: [PATCH] Add backoff reconnecting --- src/cli.ts | 235 ++++++++++++++++++++++++++++------------------------- 1 file changed, 126 insertions(+), 109 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b7aa967..212f6aa 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -101,13 +101,13 @@ function tryParseHookEvent( async function main() { const args = parseArgs(); - const { cols, rows } = getTerminalSize(); let pty: IPty | null = null; let ws: WebSocket | null = null; let isExiting = false; - let isAuthenticated = false; let reconnectTimer: Timer | null = null; + let reconnectDelay = 1000; // Start at 1s, back off exponentially + const maxReconnectDelay = 30000; // Cap at 30s const disposables: Array<{ dispose: () => void }> = []; let lineBuffer = ""; @@ -136,116 +136,120 @@ async function main() { process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); - // Spawn claude with PTY - pty = spawn("claude", args.claudeArgs, { - name: "xterm-256color", - cols, - rows, - cwd: process.cwd(), - env: process.env as Record, - }); + // Spawn PTY only after WebSocket connects + const spawnClaude = () => { + const { cols, rows } = getTerminalSize(); - // Set stdin to raw mode if TTY - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } + pty = spawn("claude", args.claudeArgs, { + name: "xterm-256color", + cols, + rows, + cwd: process.cwd(), + env: process.env as Record, + }); - // Forward local stdin to PTY - process.stdin.on("data", (data: Buffer) => { - if (pty) { - pty.write(data.toString()); + // Set stdin to raw mode if TTY + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); } - }); - // Forward PTY output to stdout AND WebSocket (if authenticated) - disposables.push( - pty.onData((data: string) => { - process.stdout.write(data); + // Forward local stdin to PTY + process.stdin.on("data", (data: Buffer) => { + if (pty) { + pty.write(data.toString()); + } + }); - if (ws && ws.readyState === WebSocket.OPEN && isAuthenticated) { - // Send output to server - const outputMsg: ClientMessage = { type: "output", data }; - ws.send(JSON.stringify(outputMsg)); + // Forward PTY output to stdout AND WebSocket + disposables.push( + pty.onData((data: string) => { + process.stdout.write(data); - // Buffer data and process complete lines for hook events - lineBuffer += data; - const lines = lineBuffer.split("\n"); - // Keep the last incomplete line in the buffer - lineBuffer = lines.pop() || ""; + if (ws && ws.readyState === WebSocket.OPEN) { + // Send output to server + const outputMsg: ClientMessage = { type: "output", data }; + ws.send(JSON.stringify(outputMsg)); - // Process each complete line for hook events - for (const line of lines) { - const event = tryParseHookEvent(line); - if (event && (event.type === "state" || event.type === "stats")) { - // Send state or stats event to server - if (event.type === "state") { - const stateMsg: ClientMessage = { - type: "state", - state: event.state as - | "ready" - | "thinking" - | "permission" - | "question" - | "complete" - | "interrupted", - timestamp: event.timestamp as number, - }; - ws.send(JSON.stringify(stateMsg)); - } else if (event.type === "stats") { - const statsMsg: ClientMessage = { - type: "stats", - prompts: event.prompts as number, - completions: event.completions as number, - tools: event.tools as number, - compressions: event.compressions as number, - thinking_seconds: event.thinking_seconds as number, - work_seconds: event.work_seconds as number, - mode: event.mode as "normal" | "auto_accept" | "plan", - model: (event.model as string) || null, - prompts_changed_at: event.prompts_changed_at as number, - completions_changed_at: event.completions_changed_at as number, - tool_timestamps: event.tool_timestamps as number[], - session_start: event.session_start as number, - idle_since: (event.idle_since as number) || null, - }; - ws.send(JSON.stringify(statsMsg)); + // Buffer data and process complete lines for hook events + lineBuffer += data; + const lines = lineBuffer.split("\n"); + // Keep the last incomplete line in the buffer + lineBuffer = lines.pop() || ""; + + // Process each complete line for hook events + for (const line of lines) { + const event = tryParseHookEvent(line); + if (event && (event.type === "state" || event.type === "stats")) { + // Send state or stats event to server + if (event.type === "state") { + const stateMsg: ClientMessage = { + type: "state", + state: event.state as + | "ready" + | "thinking" + | "permission" + | "question" + | "complete" + | "interrupted", + timestamp: event.timestamp as number, + }; + ws.send(JSON.stringify(stateMsg)); + } else if (event.type === "stats") { + const statsMsg: ClientMessage = { + type: "stats", + prompts: event.prompts as number, + completions: event.completions as number, + tools: event.tools as number, + compressions: event.compressions as number, + thinking_seconds: event.thinking_seconds as number, + work_seconds: event.work_seconds as number, + mode: event.mode as "normal" | "auto_accept" | "plan", + model: (event.model as string) || null, + prompts_changed_at: event.prompts_changed_at as number, + completions_changed_at: event.completions_changed_at as number, + tool_timestamps: event.tool_timestamps as number[], + session_start: event.session_start as number, + idle_since: (event.idle_since as number) || null, + }; + ws.send(JSON.stringify(statsMsg)); + } } } } + }), + ); + + // Handle PTY exit + disposables.push( + pty.onExit((event) => { + if (ws && !isExiting) { + const msg: ClientMessage = { type: "exit", code: event.exitCode }; + ws.send(JSON.stringify(msg)); + } + cleanup(); + }), + ); + + // Handle terminal resize + process.stdout.on("resize", () => { + if (pty && process.stdout.isTTY) { + const newCols = process.stdout.columns || 80; + const newRows = process.stdout.rows || 24; + pty.resize(newCols, newRows); + + if (ws && ws.readyState === WebSocket.OPEN) { + const msg: ClientMessage = { + type: "resize", + cols: newCols, + rows: newRows, + }; + ws.send(JSON.stringify(msg)); + } } - }), - ); + }); + }; - // Handle PTY exit - disposables.push( - pty.onExit((event) => { - if (ws && !isExiting) { - const msg: ClientMessage = { type: "exit", code: event.exitCode }; - ws.send(JSON.stringify(msg)); - } - cleanup(); - }), - ); - - // Handle terminal resize - process.stdout.on("resize", () => { - if (pty && process.stdout.isTTY) { - const newCols = process.stdout.columns || 80; - const newRows = process.stdout.rows || 24; - pty.resize(newCols, newRows); - - if (ws && ws.readyState === WebSocket.OPEN) { - const msg: ClientMessage = { - type: "resize", - cols: newCols, - rows: newRows, - }; - ws.send(JSON.stringify(msg)); - } - } - }); - - // Connect to server + // Connect to server first, then spawn claude const connect = () => { if (isExiting) return; @@ -254,6 +258,9 @@ async function main() { ws.onopen = () => { if (!ws) return; + // Reset backoff on successful connection + reconnectDelay = 1000; + const command = `claude ${args.claudeArgs.join(" ")}`; const msg: ClientMessage = { type: "auth", @@ -269,8 +276,11 @@ async function main() { const msg: ServerMessage = JSON.parse(event.data); if (msg.type === "authenticated") { - isAuthenticated = true; - console.debug(`Authenticated with session ID: ${msg.session_id}`); + console.debug(`Connected to ${args.server}`); + // Only spawn claude after authenticated + if (!pty) { + spawnClaude(); + } } else if (msg.type === "input") { if (pty) { pty.write(msg.data); @@ -287,24 +297,31 @@ async function main() { } }; - ws.onerror = (event) => { - console.error("WebSocket error:", event); + ws.onerror = () => { + // Error details come through onclose, suppress here }; ws.onclose = () => { if (isExiting) return; - // Reset auth flag on disconnect - isAuthenticated = false; + const hadPty = pty !== null; - // Try to reconnect after 2 seconds + if (hadPty) { + console.error(`Disconnected from server (retry in ${reconnectDelay / 1000}s)`); + } else { + console.error(`Waiting for server at ${args.server} (retry in ${reconnectDelay / 1000}s)`); + } + + // Exponential backoff for reconnection reconnectTimer = setTimeout(() => { - console.debug("Reconnecting to server..."); connect(); - }, 2000); + }, reconnectDelay); + + reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay); }; }; + console.error(`Connecting to ${args.server}...`); connect(); }