feat: xterm.js + node-pty architecture for Claude CLI web terminal

Complete rewrite from tmux + JSONL parsing to a clean PTY-based approach.
The proxy spawns Claude CLI in a pseudo-terminal via node-pty and relays
terminal I/O as binary WebSocket frames to the simple-chat backend,
which forwards them to the browser where xterm.js renders a full terminal.

- node-pty PTY manager with 50KB replay buffer per session
- Binary frame protocol with chatId multiplexing
- WebSocket client with auto-reconnection and heartbeat
- Directory listing and session listing for the web UI
- README with setup instructions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:24:29 +03:00
commit 4a91896732
17 changed files with 6146 additions and 0 deletions

263
src/main.ts Normal file
View File

@ -0,0 +1,263 @@
import { loadConfig } from './config.js';
import { WsClient } from './connection/ws-client.js';
import { PtyManager } from './pty/manager.js';
import type {
WsMessage,
SyncStatePayload,
CreatePtyPayload,
KillPtyPayload,
ResizePayload,
ListDirectoriesPayload,
} from './connection/protocol.js';
import {
DIR_PTY_OUTPUT,
DIR_USER_INPUT,
encodeBinaryFrame,
decodeBinaryFrame,
} from './connection/protocol.js';
import { readdir, stat } from 'fs/promises';
import { resolve } from 'path';
import { homedir } from 'os';
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
class ClaudeCliProxy {
private ws: WsClient;
private pty: PtyManager;
constructor() {
const config = loadConfig();
this.pty = new PtyManager();
this.ws = new WsClient({
url: config.serverUrl,
token: config.proxyToken,
onMessage: (msg) => this.handleMessage(msg),
onBinary: (data) => this.handleBinaryFrame(data),
onConnected: () => this.onConnected(),
onDisconnected: () => this.onDisconnected(),
});
}
async start(): Promise<void> {
console.log('[proxy] Starting claude-cli-proxy (xterm mode)...');
this.ws.connect();
process.on('SIGINT', () => this.shutdown());
process.on('SIGTERM', () => this.shutdown());
console.log('[proxy] Running. Press Ctrl+C to stop.');
}
private onConnected(): void {
console.log('[proxy] Connected to server, waiting for sync...');
}
private onDisconnected(): void {
console.log('[proxy] Disconnected from server, PTY sessions continue running');
}
// === Binary frame handling ===
private handleBinaryFrame(data: Buffer): void {
const { direction, chatId, data: payload } = decodeBinaryFrame(data);
if (direction === DIR_USER_INPUT) {
this.pty.writeToPty(chatId, payload.toString('utf-8'));
}
}
// === JSON message handling ===
private async handleMessage(msg: WsMessage): Promise<void> {
console.log(`[proxy] ← ${msg.type}`);
try {
switch (msg.type) {
case 'sync_state':
await this.handleSyncState(msg.payload as SyncStatePayload);
break;
case 'create_pty':
this.handleCreatePty(msg.payload as CreatePtyPayload);
break;
case 'kill_pty':
this.handleKillPty(msg.payload as KillPtyPayload);
break;
case 'resize':
this.handleResize(msg.payload as ResizePayload);
break;
case 'request_replay' as any: {
const { chatId } = msg.payload as { chatId: string };
const replay = this.pty.getReplayBuffer(chatId);
if (replay) {
this.ws.sendBinary(encodeBinaryFrame(DIR_PTY_OUTPUT, chatId, replay));
}
break;
}
case 'list_directories':
await this.handleListDirectories(msg.payload as ListDirectoriesPayload, msg.requestId);
break;
case 'list_sessions':
await this.handleListSessions(msg.payload as { workDir: string }, msg.requestId);
break;
default:
console.warn(`[proxy] Unknown message type: ${msg.type}`);
}
} catch (err: any) {
console.error(`[proxy] Error handling ${msg.type}:`, err);
this.ws.send({ type: 'error', payload: { message: err.message } });
}
}
private async handleSyncState(payload: SyncStatePayload): Promise<void> {
console.log(`[proxy] Syncing state: ${payload.chats.length} chats`);
const activePtys = new Set(this.pty.listPtys());
const chatIds = new Set(payload.chats.map((c) => c.id));
// Only send replay + pty_ready for PTYs that already exist
// Do NOT auto-create PTYs — they should only be created via explicit create_pty
for (const chat of payload.chats) {
if (activePtys.has(chat.id)) {
const replay = this.pty.getReplayBuffer(chat.id);
if (replay) {
this.ws.sendBinary(encodeBinaryFrame(DIR_PTY_OUTPUT, chat.id, replay));
}
this.ws.send({ type: 'pty_ready', payload: { chatId: chat.id } });
}
}
// Kill PTYs that no longer have chats
for (const chatId of activePtys) {
if (!chatIds.has(chatId)) {
this.pty.killPty(chatId);
console.log(`[proxy] Killed orphan PTY: ${chatId}`);
}
}
}
private handleCreatePty(payload: CreatePtyPayload): void {
this.pty.createPty(
payload.chatId,
payload.dir,
payload.cols,
payload.rows,
// onData — PTY output → send to backend
(data) => {
this.ws.sendBinary(encodeBinaryFrame(DIR_PTY_OUTPUT, payload.chatId, data));
},
// onExit — PTY exited
(exitCode) => {
console.log(`[proxy] PTY ${payload.chatId} exited with code ${exitCode}`);
this.ws.send({ type: 'pty_closed', payload: { chatId: payload.chatId, exitCode } });
},
payload.resumeSessionId,
);
this.ws.send({ type: 'pty_ready', payload: { chatId: payload.chatId } });
}
private handleKillPty(payload: KillPtyPayload): void {
this.pty.killPty(payload.chatId);
this.ws.send({ type: 'pty_closed', payload: { chatId: payload.chatId, exitCode: 0 } });
}
private handleResize(payload: ResizePayload): void {
this.pty.resizePty(payload.chatId, payload.cols, payload.rows);
}
// === Directory listing ===
private async handleListDirectories(payload: ListDirectoriesPayload, requestId?: string): Promise<void> {
const dirPath = payload.path.replace(/^~/, process.env.HOME || '');
const entries: Array<{ name: string; isDirectory: boolean }> = [];
try {
const items = await readdir(dirPath);
for (const item of items) {
if (item.startsWith('.')) continue;
try {
const itemStat = await stat(resolve(dirPath, item));
entries.push({ name: item, isDirectory: itemStat.isDirectory() });
} catch {
// Skip inaccessible items
}
}
} catch {
// Directory doesn't exist or can't be read
}
this.ws.send({
type: 'directories',
requestId,
payload: { requestId: requestId ?? '', entries },
});
}
// === Session listing ===
private async handleListSessions(payload: { workDir: string }, requestId?: string): Promise<void> {
const expandedDir = (payload.workDir || '').replace(/^~/, homedir());
const encoded = expandedDir.replace(/[/_]/g, '-');
const projectDir = resolve(homedir(), '.claude', 'projects', encoded);
const sessions: Array<{ id: string; prompt: string; modified: string; messages: number }> = [];
if (existsSync(projectDir)) {
const files = readdirSync(projectDir).filter((f) => f.endsWith('.jsonl'));
for (const f of files) {
try {
const fullPath = resolve(projectDir, f);
const s = statSync(fullPath);
const data = readFileSync(fullPath, 'utf-8');
const lines = data.split('\n').filter(Boolean);
let firstPrompt = '';
let messageCount = 0;
for (const line of lines) {
try {
const j = JSON.parse(line);
if (j.type === 'user' || j.type === 'assistant') messageCount++;
if (!firstPrompt && j.type === 'user' && j.message?.content) {
const c = j.message.content;
firstPrompt = typeof c === 'string'
? c.substring(0, 80)
: (Array.isArray(c) ? (c.find((b: any) => b.type === 'text')?.text?.substring(0, 80) ?? '') : '');
}
} catch {}
}
if (firstPrompt) {
sessions.push({
id: f.replace('.jsonl', ''),
prompt: firstPrompt,
modified: new Date(s.mtimeMs).toISOString(),
messages: messageCount,
});
}
} catch {}
}
sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
}
this.ws.send({
type: 'sessions_list',
requestId,
payload: { sessions: sessions.slice(0, 15) },
});
}
private shutdown(): void {
console.log('\n[proxy] Shutting down...');
this.pty.killAll();
this.ws.disconnect();
process.exit(0);
}
}
const proxy = new ClaudeCliProxy();
proxy.start().catch((err) => {
console.error('[proxy] Fatal error:', err);
process.exit(1);
});