import * as nodePty from 'node-pty'; import type { IPty } from 'node-pty'; import { EventEmitter } from 'events'; interface PtyEntry { pty: IPty; sessionId: string; windowId: string; dataListeners: Set<(data: string) => void>; exitListeners: Set<() => void>; onDataHandler: (data: string) => void; } /** * Manages PTY processes for terminal sessions. Each call to getOrCreate * spawns `tmux attach-session -t clawtap:` as a node-pty process, * giving a full bidirectional terminal stream into the existing tmux pane. * * One PTY per (sessionId, clientId) tuple — multiple clients attach * independently via separate tmux attach processes. */ export class PtySessionManager extends EventEmitter { private ptys = new Map(); // key = `${sessionId}:${clientId}` getOrCreate(sessionId: string, windowId: string, clientId: string, cols = 80, rows = 24): PtyEntry { const key = `${sessionId}:${clientId}`; const existing = this.ptys.get(key); if (existing) return existing; const pty = nodePty.spawn('tmux', ['attach-session', '-t', `clawtap:${windowId}`], { name: 'xterm-256color', cols, rows, env: process.env as Record, }); const entry: PtyEntry = { pty, sessionId, windowId, dataListeners: new Set(), exitListeners: new Set(), onDataHandler: () => {}, }; entry.onDataHandler = (data: string) => { for (const cb of entry.dataListeners) cb(data); }; pty.onData(entry.onDataHandler); pty.onExit(() => { this.ptys.delete(key); for (const cb of entry.exitListeners) cb(); }); this.ptys.set(key, entry); return entry; } onData(sessionId: string, clientId: string, cb: (data: string) => void): () => void { const key = `${sessionId}:${clientId}`; const entry = this.ptys.get(key); if (!entry) return () => {}; entry.dataListeners.add(cb); return () => entry.dataListeners.delete(cb); } onExit(sessionId: string, clientId: string, cb: () => void): () => void { const key = `${sessionId}:${clientId}`; const entry = this.ptys.get(key); if (!entry) return () => {}; entry.exitListeners.add(cb); return () => entry.exitListeners.delete(cb); } write(sessionId: string, clientId: string, data: string): void { const key = `${sessionId}:${clientId}`; this.ptys.get(key)?.pty.write(data); } resize(sessionId: string, clientId: string, cols: number, rows: number): void { const key = `${sessionId}:${clientId}`; this.ptys.get(key)?.pty.resize(cols, rows); } destroy(sessionId: string, clientId: string): void { const key = `${sessionId}:${clientId}`; const entry = this.ptys.get(key); if (!entry) return; entry.dataListeners.clear(); entry.exitListeners.clear(); try { entry.pty.kill(); } catch {} this.ptys.delete(key); } destroyAll(): void { for (const [key, entry] of this.ptys) { entry.dataListeners.clear(); entry.exitListeners.clear(); try { entry.pty.kill(); } catch {} this.ptys.delete(key); } } } export const ptyManager = new PtySessionManager();