Add backoff reconnecting

This commit is contained in:
Jared Miller 2026-01-28 18:39:04 -05:00
parent 4ab2078afd
commit b6d670deac
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View file

@ -101,13 +101,13 @@ function tryParseHookEvent(
async function main() { async function main() {
const args = parseArgs(); const args = parseArgs();
const { cols, rows } = getTerminalSize();
let pty: IPty | null = null; let pty: IPty | null = null;
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let isExiting = false; let isExiting = false;
let isAuthenticated = false;
let reconnectTimer: Timer | null = null; 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 }> = []; const disposables: Array<{ dispose: () => void }> = [];
let lineBuffer = ""; let lineBuffer = "";
@ -136,116 +136,120 @@ async function main() {
process.on("SIGINT", cleanup); process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup); process.on("SIGTERM", cleanup);
// Spawn claude with PTY // Spawn PTY only after WebSocket connects
pty = spawn("claude", args.claudeArgs, { const spawnClaude = () => {
name: "xterm-256color", const { cols, rows } = getTerminalSize();
cols,
rows,
cwd: process.cwd(),
env: process.env as Record<string, string>,
});
// Set stdin to raw mode if TTY pty = spawn("claude", args.claudeArgs, {
if (process.stdin.isTTY) { name: "xterm-256color",
process.stdin.setRawMode(true); cols,
} rows,
cwd: process.cwd(),
env: process.env as Record<string, string>,
});
// Forward local stdin to PTY // Set stdin to raw mode if TTY
process.stdin.on("data", (data: Buffer) => { if (process.stdin.isTTY) {
if (pty) { process.stdin.setRawMode(true);
pty.write(data.toString());
} }
});
// Forward PTY output to stdout AND WebSocket (if authenticated) // Forward local stdin to PTY
disposables.push( process.stdin.on("data", (data: Buffer) => {
pty.onData((data: string) => { if (pty) {
process.stdout.write(data); pty.write(data.toString());
}
});
if (ws && ws.readyState === WebSocket.OPEN && isAuthenticated) { // Forward PTY output to stdout AND WebSocket
// Send output to server disposables.push(
const outputMsg: ClientMessage = { type: "output", data }; pty.onData((data: string) => {
ws.send(JSON.stringify(outputMsg)); process.stdout.write(data);
// Buffer data and process complete lines for hook events if (ws && ws.readyState === WebSocket.OPEN) {
lineBuffer += data; // Send output to server
const lines = lineBuffer.split("\n"); const outputMsg: ClientMessage = { type: "output", data };
// Keep the last incomplete line in the buffer ws.send(JSON.stringify(outputMsg));
lineBuffer = lines.pop() || "";
// Process each complete line for hook events // Buffer data and process complete lines for hook events
for (const line of lines) { lineBuffer += data;
const event = tryParseHookEvent(line); const lines = lineBuffer.split("\n");
if (event && (event.type === "state" || event.type === "stats")) { // Keep the last incomplete line in the buffer
// Send state or stats event to server lineBuffer = lines.pop() || "";
if (event.type === "state") {
const stateMsg: ClientMessage = { // Process each complete line for hook events
type: "state", for (const line of lines) {
state: event.state as const event = tryParseHookEvent(line);
| "ready" if (event && (event.type === "state" || event.type === "stats")) {
| "thinking" // Send state or stats event to server
| "permission" if (event.type === "state") {
| "question" const stateMsg: ClientMessage = {
| "complete" type: "state",
| "interrupted", state: event.state as
timestamp: event.timestamp as number, | "ready"
}; | "thinking"
ws.send(JSON.stringify(stateMsg)); | "permission"
} else if (event.type === "stats") { | "question"
const statsMsg: ClientMessage = { | "complete"
type: "stats", | "interrupted",
prompts: event.prompts as number, timestamp: event.timestamp as number,
completions: event.completions as number, };
tools: event.tools as number, ws.send(JSON.stringify(stateMsg));
compressions: event.compressions as number, } else if (event.type === "stats") {
thinking_seconds: event.thinking_seconds as number, const statsMsg: ClientMessage = {
work_seconds: event.work_seconds as number, type: "stats",
mode: event.mode as "normal" | "auto_accept" | "plan", prompts: event.prompts as number,
model: (event.model as string) || null, completions: event.completions as number,
prompts_changed_at: event.prompts_changed_at as number, tools: event.tools as number,
completions_changed_at: event.completions_changed_at as number, compressions: event.compressions as number,
tool_timestamps: event.tool_timestamps as number[], thinking_seconds: event.thinking_seconds as number,
session_start: event.session_start as number, work_seconds: event.work_seconds as number,
idle_since: (event.idle_since as number) || null, mode: event.mode as "normal" | "auto_accept" | "plan",
}; model: (event.model as string) || null,
ws.send(JSON.stringify(statsMsg)); 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 // Connect to server first, then spawn claude
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
const connect = () => { const connect = () => {
if (isExiting) return; if (isExiting) return;
@ -254,6 +258,9 @@ async function main() {
ws.onopen = () => { ws.onopen = () => {
if (!ws) return; if (!ws) return;
// Reset backoff on successful connection
reconnectDelay = 1000;
const command = `claude ${args.claudeArgs.join(" ")}`; const command = `claude ${args.claudeArgs.join(" ")}`;
const msg: ClientMessage = { const msg: ClientMessage = {
type: "auth", type: "auth",
@ -269,8 +276,11 @@ async function main() {
const msg: ServerMessage = JSON.parse(event.data); const msg: ServerMessage = JSON.parse(event.data);
if (msg.type === "authenticated") { if (msg.type === "authenticated") {
isAuthenticated = true; console.debug(`Connected to ${args.server}`);
console.debug(`Authenticated with session ID: ${msg.session_id}`); // Only spawn claude after authenticated
if (!pty) {
spawnClaude();
}
} else if (msg.type === "input") { } else if (msg.type === "input") {
if (pty) { if (pty) {
pty.write(msg.data); pty.write(msg.data);
@ -287,24 +297,31 @@ async function main() {
} }
}; };
ws.onerror = (event) => { ws.onerror = () => {
console.error("WebSocket error:", event); // Error details come through onclose, suppress here
}; };
ws.onclose = () => { ws.onclose = () => {
if (isExiting) return; if (isExiting) return;
// Reset auth flag on disconnect const hadPty = pty !== null;
isAuthenticated = false;
// 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(() => { reconnectTimer = setTimeout(() => {
console.debug("Reconnecting to server...");
connect(); connect();
}, 2000); }, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
}; };
}; };
console.error(`Connecting to ${args.server}...`);
connect(); connect();
} }