You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

324 lines
10 KiB
TypeScript

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<T> {
data: T;
timestamp: number;
}
export class CmuxClient {
private cmuxPath: string;
private running = 0;
private queue: Array<() => void> = [];
private cache = new Map<string, CacheEntry<unknown>>();
private lastGood = new Map<string, string[]>();
private shuttingDown = false;
constructor(cmuxPath = 'cmux') {
this.cmuxPath = cmuxPath;
}
shutdown(): void {
this.shuttingDown = true;
}
private async throttle(): Promise<void> {
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<T>(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<T>(key: string, data: T): void {
this.cache.set(key, { data, timestamp: Date.now() });
}
async exec(args: string[]): Promise<string> {
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<WorkspaceInfo[]> {
const cached = this.getCached<WorkspaceInfo[]>('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<SurfaceInfo[]> {
const cacheKey = `surfaces:${workspace}:${pane || 'all'}`;
const cached = this.getCached<SurfaceInfo[]>(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<string> {
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<T>(method: string, params: Record<string, unknown>): Promise<T> {
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<string, unknown>): Promise<void> {
return this.socketQuery<void>(method, params).then(() => {});
}
private async _getSurfaceRefsForProcess(processName: string, cacheKey: string): Promise<string[]> {
const cached = this.getCached<string[]>(cacheKey);
if (cached) return cached;
// Step 1: find all matching process PIDs and extract their CMUX_SURFACE_ID
const uuids = new Set<string>();
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<string[]> {
return this._getSurfaceRefsForProcess('claude', 'claude-surfaces');
}
async getCodexSurfaceRefs(): Promise<string[]> {
return this._getSurfaceRefsForProcess('codex', 'codex-surfaces');
}
async sendText(workspace: string, surface: string, text: string): Promise<void> {
await this.sendViaSocket('surface.send_text', { workspace_id: workspace, surface_id: surface, text });
}
async sendKey(workspace: string, surface: string, key: string): Promise<void> {
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<string> {
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();
}
}