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
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();
|
|
}
|
|
}
|