368 lines
10 KiB
TypeScript
Executable file
368 lines
10 KiB
TypeScript
Executable file
#!/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 <url> WebSocket server URL (env: CLAUDE_REMOTE_SERVER)",
|
|
);
|
|
console.log(
|
|
" --secret <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<string, string>,
|
|
});
|
|
|
|
// 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);
|
|
});
|