// Interactive pseudo-terminal client connecting to the daemon's PTY service. export interface TerminalReady { id: string; cwd: string; shell: string; cols: number; rows: number; state: 'running' | 'exited'; exit_code: number | null; } export interface TerminalOptions { cwd?: string; sessionId?: string; cols?: number; rows?: number; restart?: boolean; } export class PtyClient { readonly id: string; connected = $state(false); ready = $state(null); error = $state(null); exitCode = $state(null); #ws: WebSocket | null = null; #options: TerminalOptions; #onOutput: ((data: Uint8Array | string) => void) | null = null; #onExit: ((code: number) => void) | null = null; #disposed = false; #reconnectTimer: ReturnType | undefined; constructor(id: string, options: TerminalOptions = {}) { this.id = id; this.#options = options; } onOutput(callback: (data: Uint8Array | string) => void) { this.#onOutput = callback; } onExit(callback: (code: number) => void) { this.#onExit = callback; } connect() { if (this.#disposed || this.#ws) return; clearTimeout(this.#reconnectTimer); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const params = new URLSearchParams(); if (this.#options.cwd) params.set('cwd', this.#options.cwd); if (this.#options.sessionId) params.set('session_id', this.#options.sessionId); if (this.#options.cols) params.set('cols', String(this.#options.cols)); if (this.#options.rows) params.set('rows', String(this.#options.rows)); if (this.#options.restart) params.set('restart', 'true'); const query = params.toString() ? `?${params.toString()}` : ''; const url = `${protocol}//${window.location.host}/api/terminals/${encodeURIComponent(this.id)}${query}`; try { const ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; this.#ws = ws; ws.onopen = () => { if (this.#ws !== ws) return; this.connected = true; this.error = null; }; ws.onmessage = (event: MessageEvent) => { if (this.#ws !== ws) return; if (event.data instanceof ArrayBuffer) { const bytes = new Uint8Array(event.data); this.#onOutput?.(bytes); } else if (typeof event.data === 'string') { try { const msg = JSON.parse(event.data); if (msg.type === 'ready') { this.ready = { id: msg.id, cwd: msg.cwd, shell: msg.shell, cols: msg.cols, rows: msg.rows, state: msg.state, exit_code: msg.exit_code ?? null, }; if (msg.state === 'exited' && typeof msg.exit_code === 'number') { this.exitCode = msg.exit_code; this.#onExit?.(msg.exit_code); } } else if (msg.type === 'exit') { const code = typeof msg.exit_code === 'number' ? msg.exit_code : 0; this.exitCode = code; if (this.ready) this.ready.state = 'exited'; this.#onExit?.(code); } else if (msg.type === 'error') { this.error = msg.detail || 'terminal connection error'; } } catch { // raw text output this.#onOutput?.(event.data); } } }; ws.onerror = () => { if (this.#ws !== ws) return; this.error = 'terminal socket error'; }; ws.onclose = () => { if (this.#ws !== ws) return; this.#ws = null; this.connected = false; if (!this.#disposed && this.ready?.state === 'running') { this.#reconnectTimer = setTimeout(() => this.connect(), 2000); } }; } catch (err) { this.error = String((err as Error).message ?? err); } } sendInput(data: string | Uint8Array) { if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; if (typeof data === 'string') { this.#ws.send(new TextEncoder().encode(data)); } else { const bytes = data as Uint8Array; this.#ws.send(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer); } } resize(cols: number, rows: number) { this.#options.cols = cols; this.#options.rows = rows; if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; this.#ws.send(JSON.stringify({ type: 'resize', cols, rows })); } restart(cols?: number, rows?: number) { if (this.#ws && this.#ws.readyState === WebSocket.OPEN) { this.exitCode = null; if (this.ready) this.ready.state = 'running'; this.#ws.send(JSON.stringify({ type: 'restart', cols: cols ?? this.#options.cols ?? 80, rows: rows ?? this.#options.rows ?? 24 })); } else { this.#options.restart = true; this.connect(); } } signal(sigName: 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGHUP' | 'SIGQUIT') { if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return; this.#ws.send(JSON.stringify({ type: 'signal', signal: sigName })); } disconnect() { clearTimeout(this.#reconnectTimer); if (this.#ws) { const ws = this.#ws; this.#ws = null; ws.close(); } this.connected = false; } dispose() { this.#disposed = true; this.disconnect(); } } export async function deleteTerminal(id: string): Promise { await fetch(`/api/terminals/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(() => {}); }