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

108
src/connection/protocol.ts Normal file
View File

@ -0,0 +1,108 @@
// === WebSocket Protocol Types (xterm.js + node-pty) ===
// Binary frame header for terminal data multiplexing:
// [1 byte direction][36 bytes chatId ASCII][...payload]
// Direction: 0x01 = PTY output (proxy→backend→frontend)
// 0x02 = user input (frontend→backend→proxy)
export const DIR_PTY_OUTPUT = 0x01;
export const DIR_USER_INPUT = 0x02;
export const CHAT_ID_LENGTH = 36; // UUID length
export function encodeBinaryFrame(direction: number, chatId: string, data: Buffer): Buffer {
const header = Buffer.alloc(1 + CHAT_ID_LENGTH);
header[0] = direction;
header.write(chatId, 1, CHAT_ID_LENGTH, 'ascii');
return Buffer.concat([header, data]);
}
export function decodeBinaryFrame(frame: Buffer): { direction: number; chatId: string; data: Buffer } {
const direction = frame[0];
const chatId = frame.subarray(1, 1 + CHAT_ID_LENGTH).toString('ascii');
const data = frame.subarray(1 + CHAT_ID_LENGTH);
return { direction, chatId, data };
}
// JSON control messages
export type BackendMessageType =
| 'sync_state'
| 'create_pty'
| 'kill_pty'
| 'resize'
| 'list_directories'
| 'list_sessions';
export type ProxyMessageType =
| 'pty_ready'
| 'pty_closed'
| 'directories'
| 'sessions_list'
| 'error';
export type WsMessageType = BackendMessageType | ProxyMessageType;
export interface WsMessage {
type: WsMessageType;
requestId?: string;
payload: unknown;
}
// === Payload types ===
export interface SyncStatePayload {
chats: AgentChatInfo[];
}
export interface AgentChatInfo {
id: string;
workDir: string | null;
sessionId: string | null;
cols?: number;
rows?: number;
}
export interface CreatePtyPayload {
chatId: string;
dir: string;
resumeSessionId?: string;
cols: number;
rows: number;
}
export interface KillPtyPayload {
chatId: string;
}
export interface ResizePayload {
chatId: string;
cols: number;
rows: number;
}
export interface PtyReadyPayload {
chatId: string;
}
export interface PtyClosedPayload {
chatId: string;
exitCode: number;
}
export interface ListDirectoriesPayload {
path: string;
}
export interface DirectoryEntry {
name: string;
isDirectory: boolean;
}
export interface DirectoriesPayload {
requestId: string;
entries: DirectoryEntry[];
}
export interface ErrorPayload {
chatId?: string;
message: string;
}