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:
108
src/connection/protocol.ts
Normal file
108
src/connection/protocol.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user