Files
Aperant/auto-claude-ui/src/main/terminal/pty-daemon-client.ts
T
2025-12-18 14:49:07 +01:00

414 lines
10 KiB
TypeScript

/**
* PTY Daemon Client
*
* Communicates with the PTY daemon process via Unix socket/named pipe.
* Handles connection management, automatic reconnection, and message routing.
*/
import * as net from 'net';
import * as path from 'path';
import { spawn, ChildProcess } from 'child_process';
import { app } from 'electron';
const SOCKET_PATH =
process.platform === 'win32'
? `\\\\.\\pipe\\auto-claude-pty-${process.getuid?.() || 'default'}`
: `/tmp/auto-claude-pty-${process.getuid?.() || 'default'}.sock`;
interface DaemonResponseData {
exitCode?: number;
signal?: number;
}
interface DaemonResponse {
requestId?: string;
type: string;
id?: string;
data?: string | DaemonResponseData;
error?: string;
}
type ResponseHandler = (response: DaemonResponse) => void;
interface PtyConfig {
shell: string;
shellArgs: string[];
cwd: string;
env: Record<string, string>;
rows: number;
cols: number;
}
interface PtyInfo {
id: string;
config: PtyConfig;
createdAt: number;
lastDataAt: number;
isDead: boolean;
bufferSize: number;
}
class PtyDaemonClient {
private socket: net.Socket | null = null;
private daemonProcess: ChildProcess | null = null;
private pendingRequests = new Map<string, ResponseHandler>();
private dataHandlers = new Map<string, (data: string) => void>();
private exitHandlers = new Map<string, (exitCode: number, signal?: number) => void>();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private isConnecting = false;
private buffer = '';
private isShuttingDown = false;
/**
* Connect to daemon, spawning if necessary
*/
async connect(): Promise<void> {
if (this.isShuttingDown) {
throw new Error('Client is shutting down');
}
if (this.socket || this.isConnecting) return;
this.isConnecting = true;
try {
// Try to connect to existing daemon
await this.tryConnect();
console.warn('[PtyDaemonClient] Connected to existing daemon');
} catch {
// Spawn daemon and connect
console.warn('[PtyDaemonClient] Spawning new daemon...');
await this.spawnDaemon();
await this.tryConnect();
console.warn('[PtyDaemonClient] Connected to new daemon');
} finally {
this.isConnecting = false;
this.reconnectAttempts = 0;
}
}
/**
* Try to connect to existing daemon
*/
private tryConnect(): Promise<void> {
return new Promise((resolve, reject) => {
const socket = net.connect(SOCKET_PATH);
const timeout = setTimeout(() => {
socket.destroy();
reject(new Error('Connection timeout'));
}, 3000);
socket.on('connect', () => {
clearTimeout(timeout);
this.socket = socket;
this.setupSocketHandlers();
resolve();
});
socket.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* Spawn a new daemon process
*/
private async spawnDaemon(): Promise<void> {
// In production, the daemon file is in the same directory
const daemonPath = path.join(__dirname, 'pty-daemon.js');
try {
// Spawn detached process that survives parent
this.daemonProcess = spawn(process.execPath, [daemonPath], {
detached: true,
stdio: 'ignore', // Don't pipe stdout/stderr
env: { ...process.env },
});
// Unref so parent can exit independently
this.daemonProcess.unref();
console.warn(`[PtyDaemonClient] Spawned daemon process (PID: ${this.daemonProcess.pid})`);
// Wait for daemon to start listening
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.error('[PtyDaemonClient] Failed to spawn daemon:', error);
throw error;
}
}
/**
* Setup socket event handlers
*/
private setupSocketHandlers(): void {
if (!this.socket) return;
this.socket.on('data', (chunk) => {
this.buffer += chunk.toString();
// Handle newline-delimited JSON
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const response = JSON.parse(line);
this.handleResponse(response);
} catch (e) {
console.error('[PtyDaemonClient] Invalid response:', e);
}
}
});
this.socket.on('close', () => {
console.warn('[PtyDaemonClient] Disconnected from daemon');
this.socket = null;
if (!this.isShuttingDown) {
this.attemptReconnect();
}
});
this.socket.on('error', (err) => {
console.error('[PtyDaemonClient] Socket error:', err);
});
}
/**
* Handle response from daemon
*/
private handleResponse(response: DaemonResponse): void {
// Handle request-response pattern
if (response.requestId) {
const handler = this.pendingRequests.get(response.requestId);
if (handler) {
this.pendingRequests.delete(response.requestId);
handler(response);
}
return;
}
// Handle streaming data
if (response.type === 'data' && response.id) {
const handler = this.dataHandlers.get(response.id);
if (handler && typeof response.data === 'string') {
handler(response.data);
}
return;
}
// Handle exit events
if (response.type === 'exit' && response.id) {
const handler = this.exitHandlers.get(response.id);
if (handler && typeof response.data === 'object' && response.data !== null) {
const exitData = response.data as DaemonResponseData;
handler(exitData.exitCode ?? 0, exitData.signal);
}
return;
}
}
/**
* Attempt to reconnect with exponential backoff
*/
private attemptReconnect(): void {
if (this.isShuttingDown) return;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[PtyDaemonClient] Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
console.warn(
`[PtyDaemonClient] Reconnect attempt ${this.reconnectAttempts} in ${delay}ms...`
);
setTimeout(() => {
this.connect().catch((error) => {
console.error('[PtyDaemonClient] Reconnect failed:', error);
});
}, delay);
}
/**
* Send a request and wait for response
*/
private async request<T>(msg: Record<string, unknown>): Promise<T> {
await this.connect();
if (!this.socket) {
throw new Error('Not connected to daemon');
}
const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}, 10000);
this.pendingRequests.set(requestId, (response) => {
clearTimeout(timeout);
if (response.type === 'error') {
reject(new Error(response.error || 'Unknown error'));
} else {
resolve(response as T);
}
});
this.socket!.write(JSON.stringify({ ...msg, requestId }) + '\n');
});
}
/**
* Send a message without expecting a response
*/
private send(msg: Record<string, unknown>): void {
if (!this.socket) {
console.warn('[PtyDaemonClient] Cannot send - not connected');
return;
}
this.socket.write(JSON.stringify(msg) + '\n');
}
// ===== Public API =====
/**
* Create a new PTY in the daemon
*/
async createPty(config: PtyConfig): Promise<string> {
const response = await this.request<{ type: 'created'; id: string }>({
type: 'create',
data: config,
});
return response.id;
}
/**
* Write data to a PTY
*/
write(id: string, data: string): void {
this.send({ type: 'write', id, data });
}
/**
* Resize a PTY
*/
resize(id: string, cols: number, rows: number): void {
this.send({ type: 'resize', id, data: { cols, rows } });
}
/**
* Kill a PTY
*/
kill(id: string): void {
this.send({ type: 'kill', id });
this.dataHandlers.delete(id);
this.exitHandlers.delete(id);
}
/**
* List all PTYs in the daemon
*/
async list(): Promise<PtyInfo[]> {
const response = await this.request<{ type: 'list'; data: PtyInfo[] }>({
type: 'list',
});
return response.data;
}
/**
* Subscribe to PTY output and exit events
*/
subscribe(
id: string,
onData: (data: string) => void,
onExit: (code: number, signal?: number) => void
): void {
this.dataHandlers.set(id, onData);
this.exitHandlers.set(id, onExit);
this.send({ type: 'subscribe', id });
}
/**
* Unsubscribe from PTY events
*/
unsubscribe(id: string): void {
this.dataHandlers.delete(id);
this.exitHandlers.delete(id);
this.send({ type: 'unsubscribe', id });
}
/**
* Get buffered output from a PTY
*/
async getBuffer(id: string): Promise<{ buffer: string; isDead: boolean }> {
const response = await this.request<{
type: 'buffer';
data: { buffer: string; isDead: boolean };
}>({
type: 'get-buffer',
id,
});
return response.data;
}
/**
* Check if daemon is alive
*/
async ping(): Promise<boolean> {
try {
await this.request<{ type: 'pong' }>({ type: 'ping' });
return true;
} catch {
return false;
}
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.socket !== null;
}
/**
* Disconnect from daemon (does not kill daemon)
*/
disconnect(): void {
if (this.socket) {
this.isShuttingDown = true;
this.socket.end();
this.socket = null;
}
}
/**
* Cleanup on app shutdown
*/
shutdown(): void {
this.isShuttingDown = true;
this.disconnect();
this.pendingRequests.clear();
this.dataHandlers.clear();
this.exitHandlers.clear();
}
}
// Singleton instance
export const ptyDaemonClient = new PtyDaemonClient();
// Cleanup on app quit
app.on('before-quit', () => {
ptyDaemonClient.shutdown();
});