/** * backends/cmux.ts — Cmux 终端后端 * * 包含 Cmux 特定的 surface 创建(子 agent pane 复用)、命令发送、 * 屏幕读写、焦点恢复、关闭与重命名。 * parseCmux* 纯函数作为公开 API 从此文件导出。 */ import { execSync, execFileSync, spawnSync } from "node:child_process"; import { shellEscape } from "../shell.ts"; import { createBackendLogger } from "./shared.ts"; import type { BackendOps } from "./types.ts"; /** Cmux 后端日志(统一格式,写入 /tmp/pi-mux-cmux.log) */ const cmuxLog = createBackendLogger("cmux", "/tmp/pi-mux-cmux.log"); // ── 内部辅助 ── /** Tracked subagent pane for cmux — reused across subagent launches. */ let cmuxSubagentPane: string | null = null; type CmuxFocusSnapshot = { surfaceRef?: string; paneRef?: string; }; type CmuxCreatedSurface = { surface: string; paneRef?: string; }; type CmuxIdentifySnapshot = { focused: CmuxFocusSnapshot | null; caller: CmuxFocusSnapshot | null; }; /** 类型守卫:非空字符串 */ function nonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } /** 执行 cmux 命令并返回 stdout,失败返回 null */ function readCmux(args: string[]): string | null { const result = spawnSync("cmux", args, { encoding: "utf8" }); if (result.error || result.status !== 0 || !result.stdout.trim()) return null; return result.stdout; } // ── parseCmux* 公开纯函数 ── export function parseCmuxFocusedSnapshot(value: unknown): CmuxFocusSnapshot | null { if (!value || typeof value !== "object") return null; const focused = (value as { focused?: unknown }).focused; if (!focused || typeof focused !== "object") return null; const record = focused as { surface_ref?: unknown; pane_ref?: unknown }; const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined; const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined; if (!surfaceRef && !paneRef) return null; return { surfaceRef, paneRef }; } export function parseCmuxJson(value: string): unknown | null { try { return JSON.parse(value); } catch (error) { void error; return null; } } export function parseCmuxFocusedSnapshotFromJson(value: string): CmuxFocusSnapshot | null { return parseCmuxFocusedSnapshot(parseCmuxJson(value)); } function parseCmuxCallerSnapshot(value: unknown): CmuxFocusSnapshot | null { if (!value || typeof value !== "object") return null; const caller = (value as { caller?: unknown }).caller; if (!caller || typeof caller !== "object") return null; const record = caller as { surface_ref?: unknown; pane_ref?: unknown }; const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined; const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined; if (!surfaceRef && !paneRef) return null; return { surfaceRef, paneRef }; } export function parseCmuxPaneRefForSurface(value: unknown, surface: string): string | null { if (!value || typeof value !== "object") return null; const record = value as { surface_ref?: unknown; pane_ref?: unknown; caller?: unknown }; if (record.surface_ref === surface && nonEmptyString(record.pane_ref)) return record.pane_ref; const caller = record.caller; if (!caller || typeof caller !== "object") return null; const callerRecord = caller as { surface_ref?: unknown; pane_ref?: unknown }; if (callerRecord.surface_ref === surface && nonEmptyString(callerRecord.pane_ref)) { return callerRecord.pane_ref; } return null; } export function parseCmuxPaneRefForSurfaceFromJson(value: string, surface: string): string | null { return parseCmuxPaneRefForSurface(parseCmuxJson(value), surface); } // ── Cmux 内部操作 ── function parseCmuxIdentifySnapshot(value: string | null): CmuxIdentifySnapshot { const parsed = value ? parseCmuxJson(value) : null; return { focused: parseCmuxFocusedSnapshot(parsed), caller: parseCmuxCallerSnapshot(parsed), }; } function captureCmuxIdentifySnapshot(): CmuxIdentifySnapshot { return parseCmuxIdentifySnapshot(readCmux(["identify", "--json"])); } function captureCmuxFocusSnapshot(): CmuxFocusSnapshot | null { return captureCmuxIdentifySnapshot().focused; } function readCmuxPaneRefForSurface(surface: string): string | null { const info = readCmux(["identify", "--surface", surface]); return info ? parseCmuxPaneRefForSurfaceFromJson(info, surface) : null; } function restoreCmuxFocusSnapshot(snapshot: CmuxFocusSnapshot | null): void { if (!snapshot) return; if (snapshot.paneRef) { spawnSync("cmux", ["focus-pane", "--pane", snapshot.paneRef], { encoding: "utf8" }); } if (snapshot.surfaceRef) { spawnSync("cmux", ["focus-panel", "--panel", snapshot.surfaceRef], { encoding: "utf8" }); } } /** 等待 cmux 焦点稳定(100ms) */ function waitForCmuxFocusSettle(): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); } function cmuxFocusMatchesChild( currentFocus: CmuxFocusSnapshot | null, child: CmuxCreatedSurface, ): boolean { if (!currentFocus) return false; if (currentFocus.surfaceRef === child.surface) return true; return !!currentFocus.paneRef && currentFocus.paneRef === child.paneRef; } function cmuxFocusMatchesSurfaceRef( currentFocus: CmuxFocusSnapshot | null, surfaceRef: string | undefined, ): boolean { return !!surfaceRef && currentFocus?.surfaceRef === surfaceRef; } function cmuxFocusMatchesPaneRef( currentFocus: CmuxFocusSnapshot | null, paneRef: string | undefined, ): boolean { return !!paneRef && currentFocus?.paneRef === paneRef; } function restoreCmuxFocusIfLaunchSurfaceFocused( snapshot: CmuxFocusSnapshot | null, child: CmuxCreatedSurface, options?: { sourceSurfaceRef?: string; callerSnapshot?: CmuxFocusSnapshot | null }, ): void { if (!snapshot) return; waitForCmuxFocusSettle(); const currentFocus = captureCmuxFocusSnapshot(); if ( cmuxFocusMatchesChild(currentFocus, child) || cmuxFocusMatchesSurfaceRef(currentFocus, options?.sourceSurfaceRef) || cmuxFocusMatchesSurfaceRef(currentFocus, options?.callerSnapshot?.surfaceRef) || // cmux can settle focus onto another active surface in the caller pane after creating a split/surface. cmuxFocusMatchesPaneRef(currentFocus, options?.callerSnapshot?.paneRef) ) { restoreCmuxFocusSnapshot(snapshot); } } function parseCmuxCreatedSurface(output: string, command: string): CmuxCreatedSurface { const surfaceMatch = output.match(/surface:\d+/); if (!surfaceMatch) { throw new Error(`Unexpected cmux ${command} output: ${output}`); } return { surface: surfaceMatch[0], paneRef: output.match(/pane:\d+/)?.[0], }; } function renameCmuxSurface(surface: string, name: string): void { execFileSync("cmux", ["rename-tab", "--surface", surface, name], { encoding: "utf8" }); } function createCmuxSplitSurface( name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string, ): CmuxCreatedSurface { const identifySnapshot = captureCmuxIdentifySnapshot(); const focusSnapshot = identifySnapshot.focused; const callerSnapshot = identifySnapshot.caller; let child: CmuxCreatedSurface | null = null; try { const args = ["new-split", direction]; if (fromSurface) args.push("--surface", fromSurface); const output = execFileSync("cmux", args, { encoding: "utf8" }).trim(); child = parseCmuxCreatedSurface(output, "new-split"); child.paneRef ??= readCmuxPaneRefForSurface(child.surface) ?? undefined; renameCmuxSurface(child.surface, name); return child; } finally { if (child) { restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, { sourceSurfaceRef: fromSurface, callerSnapshot, }); } else { restoreCmuxFocusSnapshot(focusSnapshot); } } } /** * 在现有 cmux pane 中创建新 surface(tab)。 */ function createSurfaceInPane(name: string, pane: string): string { const identifySnapshot = captureCmuxIdentifySnapshot(); const focusSnapshot = identifySnapshot.focused; const callerSnapshot = identifySnapshot.caller; let child: CmuxCreatedSurface | null = null; try { const output = execFileSync("cmux", ["new-surface", "--pane", pane], { encoding: "utf8" }).trim(); child = parseCmuxCreatedSurface(output, "new-surface"); child.paneRef ??= pane; renameCmuxSurface(child.surface, name); return child.surface; } finally { if (child) { restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, { callerSnapshot, }); } else { restoreCmuxFocusSnapshot(focusSnapshot); } } } // ── BackendOps ── export const ops: BackendOps = { create(name: string): string { // 优先复用已有 subagent pane(在其内创建新 tab) if (cmuxSubagentPane) { try { const tree = execSync(`cmux tree`, { encoding: "utf8" }); if (tree.includes(cmuxSubagentPane)) { const surface = createSurfaceInPane(name, cmuxSubagentPane); cmuxLog( `[split] mode=tab-reuse pane=${cmuxSubagentPane} new=${surface} name=${JSON.stringify(name)}`, ); return surface; } } catch {} // Pane 已消失 — fall through 创建新 split cmuxSubagentPane = null; } const created = createCmuxSplitSurface(name, "right", process.env.CMUX_SURFACE_ID); cmuxSubagentPane = created.paneRef ?? null; cmuxLog( `[split] mode=first dir=right from=${process.env.CMUX_SURFACE_ID ?? ""} new=${created.surface} name=${JSON.stringify(name)}`, ); return created.surface; }, createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string): string { const surface = createCmuxSplitSurface(name, direction, fromSurface).surface; cmuxLog( `[split] mode=createSurfaceSplit dir=${direction} from=${fromSurface ?? ""} new=${surface} name=${JSON.stringify(name)}`, ); return surface; }, send(surface: string, command: string): void { execSync(`cmux send --surface ${shellEscape(surface)} ${shellEscape(command + "\n")}`, { encoding: "utf8", }); }, sendEscape(surface: string): void { execFileSync("cmux", ["send", "--surface", surface, "\u001b"], { encoding: "utf8" }); }, read(surface: string, lines = 50): string { return execSync(`cmux read-screen --surface ${shellEscape(surface)} --lines ${lines}`, { encoding: "utf8", }); }, async readAsync(surface: string, lines = 50): Promise { const { promisify } = await import("node:util"); const { execFile } = await import("node:child_process"); const { stdout } = await promisify(execFile)( "cmux", ["read-screen", "--surface", surface, "--lines", String(lines)], { encoding: "utf8" }, ); return stdout; }, close(surface: string): void { execSync(`cmux close-surface --surface ${shellEscape(surface)}`, { encoding: "utf8", }); cmuxLog(`[close] surface=${surface}`); }, rename(surface: string, name: string): void { renameCmuxSurface(surface, name); }, }; /** * 获取 cmux 子 agent pane(供 surface.ts 设置 lastSplitSource)。 * 返回 null 表示尚无 subagent pane。 */ export function getCmuxSubagentPane(): string | null { return cmuxSubagentPane; }