Add backoff reconnecting
This commit is contained in:
parent
4ab2078afd
commit
b6d670deac
1 changed files with 126 additions and 109 deletions
235
src/cli.ts
235
src/cli.ts
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue