Support dual configuration: PROXY_TOKEN/SERVER_URL for production, DEV_PROXY_TOKEN/DEV_SERVER_URL for local development. - npm start → production - npm run start:dev → dev (PROXY_MODE=dev) - npm run dev → dev with hot-reload - Restore PTY auto-creation on sync_state for existing chats Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
274 lines
8.5 KiB
TypeScript
274 lines
8.5 KiB
TypeScript
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;
|
|
private config: ReturnType<typeof loadConfig>;
|
|
|
|
constructor() {
|
|
this.config = loadConfig();
|
|
|
|
this.pty = new PtyManager();
|
|
|
|
this.ws = new WsClient({
|
|
url: this.config.serverUrl,
|
|
token: this.config.proxyToken,
|
|
onMessage: (msg) => this.handleMessage(msg),
|
|
onBinary: (data) => this.handleBinaryFrame(data),
|
|
onConnected: () => this.onConnected(),
|
|
onDisconnected: () => this.onDisconnected(),
|
|
});
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
const mode = this.config.isDev ? 'DEV' : 'PROD';
|
|
console.log(`[proxy] Starting claude-cli-proxy (${mode}) → ${this.config.serverUrl}`);
|
|
|
|
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));
|
|
|
|
for (const chat of payload.chats) {
|
|
if (activePtys.has(chat.id)) {
|
|
// PTY exists — send replay + ready
|
|
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 } });
|
|
} else if (chat.workDir) {
|
|
// PTY not running — create it
|
|
this.handleCreatePty({
|
|
chatId: chat.id,
|
|
dir: chat.workDir,
|
|
resumeSessionId: chat.sessionId ?? undefined,
|
|
cols: chat.cols ?? 120,
|
|
rows: chat.rows ?? 40,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
});
|