import { execFile } from 'node:child_process'; import { readFile, unlink } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { promisify } from 'node:util'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import type { WorkspaceInfo, PaneInfo, SurfaceInfo } from '../protocol/messages.js'; const execFileAsync = promisify(execFile); const MAX_CONCURRENT = 5; const CACHE_TTL_MS = 2000; interface CacheEntry { data: T; timestamp: number; } export class CmuxClient { private cmuxPath: string; private running = 0; private queue: Array<() => void> = []; private cache = new Map>(); private lastGood = new Map(); private shuttingDown = false; constructor(cmuxPath = 'cmux') { this.cmuxPath = cmuxPath; } shutdown(): void { this.shuttingDown = true; } private async throttle(): Promise { if (this.running < MAX_CONCURRENT) { this.running++; return; } return new Promise((resolve) => { this.queue.push(() => { this.running++; resolve(); }); }); } private release(): void { this.running--; const next = this.queue.shift(); if (next) next(); } private getCached(key: string): T | null { const entry = this.cache.get(key); if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) { return entry.data as T; } this.cache.delete(key); return null; } private setCache(key: string, data: T): void { this.cache.set(key, { data, timestamp: Date.now() }); } async exec(args: string[]): Promise { if (this.shuttingDown) return ''; await this.throttle(); try { const { stdout } = await execFileAsync(this.cmuxPath, args, { timeout: 10000, maxBuffer: 1024 * 1024, }); return stdout; } catch (err) { if (this.shuttingDown) return ''; throw err; } finally { this.release(); } } async listWorkspaces(): Promise { const cached = this.getCached('workspaces'); if (cached) return cached; const output = await this.exec(['tree', '--all']); const workspaces = this.parseTree(output); this.setCache('workspaces', workspaces); return workspaces; } parseTree(output: string): WorkspaceInfo[] { const workspaces: WorkspaceInfo[] = []; let currentWs: WorkspaceInfo | null = null; let currentPane: PaneInfo | null = null; for (const line of output.split('\n')) { // Strip tree-drawing characters and leading whitespace const stripped = line.replace(/^[\s│├└─┬┤┼┌┐┘┴]+/g, '').trim(); if (!stripped) continue; // Match workspace lines: "workspace workspace:1 "name" [flags]" const wsMatch = stripped.match(/^workspace\s+(workspace:\d+)\s*(.*)/); if (wsMatch) { // Extract quoted name if present const nameMatch = wsMatch[2].match(/"([^"]+)"/); currentWs = { id: wsMatch[1], ref: wsMatch[1], name: nameMatch?.[1] || undefined, panes: [], }; workspaces.push(currentWs); currentPane = null; continue; } // Match pane lines: "pane pane:2 [flags]" const paneMatch = stripped.match(/^pane\s+(pane:\d+)/); if (paneMatch && currentWs) { currentPane = { id: paneMatch[1], ref: paneMatch[1], surfaces: [], }; currentWs.panes.push(currentPane); continue; } // Match surface lines: "surface surface:2 [terminal] "title" [flags]" const surfaceMatch = stripped.match(/^surface\s+(surface:\d+)\s*(?:\[(\w+)\])?\s*(.*)/); if (surfaceMatch && currentPane) { const titleMatch = surfaceMatch[3].match(/"([^"]+)"/); currentPane.surfaces.push({ id: surfaceMatch[1], ref: surfaceMatch[1], type: surfaceMatch[2] || 'terminal', title: titleMatch?.[1] || undefined, }); } } return workspaces; } async listPaneSurfaces(workspace: string, pane?: string): Promise { const cacheKey = `surfaces:${workspace}:${pane || 'all'}`; const cached = this.getCached(cacheKey); if (cached) return cached; const args = ['list-pane-surfaces', '--workspace', workspace]; if (pane) args.push('--pane', pane); const output = await this.exec(args); const surfaces = this.parseSurfaces(output); this.setCache(cacheKey, surfaces); return surfaces; } parseSurfaces(output: string): SurfaceInfo[] { const surfaces: SurfaceInfo[] = []; for (const line of output.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; const match = trimmed.match(/^(surface:\d+)\s*(?:\[(\w+)\])?\s*(.*)/); if (match) { surfaces.push({ id: match[1], ref: match[1], type: match[2] || 'terminal', title: match[3]?.trim() || undefined, }); } } return surfaces; } async readScreen( workspace: string, surface: string, opts?: { scrollback?: boolean; lines?: number } ): Promise { const args = ['read-screen', '--workspace', workspace, '--surface', surface]; if (opts?.scrollback) args.push('--scrollback'); if (opts?.lines) args.push('--lines', String(opts.lines)); return this.exec(args); } private get socketPath(): string { return process.env.CMUX_SOCKET_PATH || join(homedir(), 'Library', 'Application Support', 'cmux', 'cmux.sock'); } private socketQuery(method: string, params: Record): Promise { return new Promise((resolve, reject) => { const id = Math.random().toString(36).slice(2); const conn = createConnection(this.socketPath, () => { conn.write(JSON.stringify({ id, method, params }) + '\n'); }); const timeout = setTimeout(() => { conn.destroy(); reject(new Error(`Socket timed out after 10s (method: ${method})`)); }, 10000); conn.on('data', (data) => { clearTimeout(timeout); conn.destroy(); try { const msg = JSON.parse(data.toString()); if (msg.ok) resolve(msg.result as T); else reject(new Error(msg.error?.message || 'socket error')); } catch { reject(new Error('Invalid response from cmux socket')); } }); conn.on('error', (err) => { clearTimeout(timeout); reject(err); }); }); } private sendViaSocket(method: string, params: Record): Promise { return this.socketQuery(method, params).then(() => {}); } private async _getSurfaceRefsForProcess(processName: string, cacheKey: string): Promise { const cached = this.getCached(cacheKey); if (cached) return cached; // Step 1: find all matching process PIDs and extract their CMUX_SURFACE_ID const uuids = new Set(); try { const { stdout: psListOut } = await execFileAsync('ps', ['-axo', 'pid=,args='], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 }); const pids: string[] = []; for (const line of psListOut.split('\n')) { const parts = line.trim().split(/\s+/); if (parts.length < 2) continue; // Check all args tokens: handles both native binaries (/usr/bin/claude) // and Node.js wrappers (node /usr/local/bin/claude ...). const matched = parts.slice(1).some( p => p === processName || p.endsWith(`/${processName}`) ); if (matched) pids.push(parts[0]); } for (const pid of pids) { try { const { stdout: psOut } = await execFileAsync('ps', ['-E', '-p', pid], { timeout: 5000 }); const match = psOut.match(/CMUX_SURFACE_ID=([A-F0-9-]+)/i); if (match) uuids.add(match[1]); } catch { /* skip */ } } } catch { /* no matching processes */ } if (uuids.size === 0) { // Don't cache empty — transient ps failure; return last known good result. return this.lastGood.get(cacheKey) ?? []; } // Step 2: get ALL workspaces via cmux tree --all (covers every window, not just // the active one that workspace.list socket returns). const allWorkspaces = await this.listWorkspaces(); // Step 3: for each workspace, get surfaces and match UUID → ref const refs: string[] = []; for (const ws of allWorkspaces) { try { const surfResult = await this.socketQuery<{ surfaces: Array<{ id: string; ref: string }> }>('surface.list', { workspace_id: ws.ref }); for (const s of surfResult.surfaces) { if (uuids.has(s.id)) refs.push(s.ref); } } catch { /* skip workspace */ } } if (refs.length > 0) this.lastGood.set(cacheKey, refs); // Only cache non-empty results; empty may mean transient socket failure. if (refs.length > 0) this.setCache(cacheKey, refs); return refs.length > 0 ? refs : (this.lastGood.get(cacheKey) ?? []); } async getClaudeCodeSurfaceRefs(): Promise { return this._getSurfaceRefsForProcess('claude', 'claude-surfaces'); } async getCodexSurfaceRefs(): Promise { return this._getSurfaceRefsForProcess('codex', 'codex-surfaces'); } async sendText(workspace: string, surface: string, text: string): Promise { await this.sendViaSocket('surface.send_text', { workspace_id: workspace, surface_id: surface, text }); } async sendKey(workspace: string, surface: string, key: string): Promise { await this.sendViaSocket('surface.send_key', { workspace_id: workspace, surface_id: surface, key }); } async browserScreenshot(workspace: string, surface: string): Promise<{ data: string; mime: string }> { const tmpFile = join(tmpdir(), `cmux-screenshot-${surface}-${Date.now()}.jpg`); try { await this.exec(['browser', '--surface', surface, 'screenshot', '--out', tmpFile]); const data = await readFile(tmpFile); return { data: data.toString('base64'), mime: 'image/jpeg' }; } finally { await unlink(tmpFile).catch(() => {}); } } async capturePaneScrollback( workspace: string, surface: string, lines?: number ): Promise { const args = ['capture-pane', '--workspace', workspace, '--surface', surface, '--scrollback']; if (lines) args.push('--lines', String(lines)); return this.exec(args); } clearCache(): void { this.cache.clear(); } }