#!/usr/bin/env bun // PTY wrapper for claude CLI // Check for Bun runtime (only fails when running bundled .js with Node) if (typeof Bun === "undefined") { console.error("claude-remote requires Bun to run."); console.error("Install Bun: https://bun.sh"); process.exit(1); } import type { IPty } from "bun-pty"; import { spawn } from "bun-pty"; import type { ClientMessage, ServerMessage } from "./types"; interface Args { server: string; secret: string; claudeArgs: string[]; } function parseArgs(): Args { const args = process.argv.slice(2); // Read config from env vars first let server = process.env.CLAUDE_REMOTE_SERVER || "ws://localhost:7200/ws"; let secret = process.env.CLAUDE_REMOTE_SECRET || ""; const claudeArgs: string[] = []; let i = 0; while (i < args.length) { if (args[i] === "--server" && i + 1 < args.length) { server = args[i + 1] as string; i += 2; } else if (args[i] === "--secret" && i + 1 < args.length) { secret = args[i + 1] as string; i += 2; } else if (args[i] === "--help" || args[i] === "-h") { // Run claude --help synchronously and append our section const result = Bun.spawnSync(["claude", "--help"]); if (result.success) { process.stdout.write(result.stdout); console.log(""); console.log("Remote:"); console.log( " --server WebSocket server URL (env: CLAUDE_REMOTE_SERVER)", ); console.log( " --secret Authentication secret (env: CLAUDE_REMOTE_SECRET)", ); console.log(""); } else { console.error("Failed to run 'claude --help'. Is claude installed?"); } process.exit(result.exitCode ?? 0); } else if (args[i] === "--") { // -- separator is optional, just skip it claudeArgs.push(...args.slice(i + 1)); break; } else { claudeArgs.push(args[i] as string); i++; } } if (!secret) { console.error( "No secret configured. Set CLAUDE_REMOTE_SECRET or use --secret", ); process.exit(1); } return { server, secret, claudeArgs }; } function getTerminalSize(): { cols: number; rows: number } { if (process.stdout.isTTY) { return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24, }; } return { cols: 80, rows: 24 }; } function tryParseHookEvent( line: string, ): { type: string; [key: string]: unknown } | null { // Look for lines that are valid JSON objects const trimmed = line.trim(); if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { return null; } try { const parsed = JSON.parse(trimmed); // Check if it has a type field if (typeof parsed === "object" && parsed !== null && "type" in parsed) { return parsed as { type: string; [key: string]: unknown }; } return null; } catch { return null; } } async function main() { const args = parseArgs(); let pty: IPty | null = null; let ws: WebSocket | null = null; let isExiting = 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 = ""; const cleanup = () => { if (isExiting) return; isExiting = true; if (reconnectTimer) { clearTimeout(reconnectTimer); } for (const d of disposables) { d.dispose(); } if (process.stdin.isTTY) { process.stdin.setRawMode(false); } if (pty) { pty.kill(); } if (ws) { ws.close(); } process.exit(0); }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); process.on("SIGCONT", () => { // Restore terminal state after being resumed from suspension if (process.stdin.isTTY) { process.stdin.setRawMode(true); } process.stdin.resume(); if (pty) { // Resume child process first process.kill(pty.pid, "SIGCONT"); // Give child time to wake up, then trigger redraw setTimeout(() => { if (pty && !isExiting) { const { cols, rows } = getTerminalSize(); pty.resize(cols, rows); process.kill(pty.pid, "SIGWINCH"); // Tell child window changed } }, 50); } }); // Spawn PTY only after WebSocket connects const spawnClaude = () => { const { cols, rows } = getTerminalSize(); pty = spawn("claude", args.claudeArgs, { name: "xterm-256color", cols, rows, cwd: process.cwd(), env: process.env as Record, }); // Set stdin to raw mode if TTY if (process.stdin.isTTY) { process.stdin.setRawMode(true); } // Forward local stdin to PTY process.stdin.on("data", (data: Buffer) => { // ctrl+z in raw mode - suspend child and self if (data.length === 1 && data[0] === 0x1a) { if (pty) { process.kill(pty.pid, "SIGTSTP"); } process.kill(process.pid, "SIGTSTP"); return; } if (pty) { pty.write(data.toString()); } }); // Forward PTY output to stdout AND WebSocket disposables.push( pty.onData((data: string) => { process.stdout.write(data); if (ws && ws.readyState === WebSocket.OPEN) { // Send output to server const outputMsg: ClientMessage = { type: "output", data }; ws.send(JSON.stringify(outputMsg)); // 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)); } } }); }; // Connect to server first, then spawn claude const connect = () => { if (isExiting) return; ws = new WebSocket(args.server); ws.onopen = () => { if (!ws) return; // Reset backoff on successful connection reconnectDelay = 1000; const command = `claude ${args.claudeArgs.join(" ")}`; const msg: ClientMessage = { type: "auth", secret: args.secret, cwd: process.cwd(), command, }; ws.send(JSON.stringify(msg)); }; ws.onmessage = (event) => { try { const msg: ServerMessage = JSON.parse(event.data); if (msg.type === "authenticated") { 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); } } else if (msg.type === "resize") { if (pty) { pty.resize(msg.cols, msg.rows); } } else if (msg.type === "ping") { // Acknowledge ping (keep-alive) } } catch (err) { console.error("Failed to parse server message:", err); } }; ws.onerror = () => { // Error details come through onclose, suppress here }; ws.onclose = () => { if (isExiting) return; const hadPty = pty !== null; 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(() => { connect(); }, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay); }; }; console.error(`Connecting to ${args.server}...`); connect(); } main().catch((err) => { console.error("Fatal error:", err); process.exit(1); });