From bd990a07ab2ff614a41c7ca4e036421f69735c73 Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 15 May 2026 11:28:45 +0200 Subject: [PATCH] feat(T-1.1): tmux manager, control-mode client, input, snapshot --- extensions/remote-control/tmux/control.ts | 171 ++++++++++++++++++++ extensions/remote-control/tmux/input.ts | 89 +++++++++++ extensions/remote-control/tmux/manager.ts | 175 +++++++++++++++++++++ extensions/remote-control/tmux/snapshot.ts | 57 +++++++ 4 files changed, 492 insertions(+) create mode 100644 extensions/remote-control/tmux/control.ts create mode 100644 extensions/remote-control/tmux/input.ts create mode 100644 extensions/remote-control/tmux/manager.ts create mode 100644 extensions/remote-control/tmux/snapshot.ts diff --git a/extensions/remote-control/tmux/control.ts b/extensions/remote-control/tmux/control.ts new file mode 100644 index 0000000..448f7c9 --- /dev/null +++ b/extensions/remote-control/tmux/control.ts @@ -0,0 +1,171 @@ +/** + * tmux control-mode client — per-session. + * + * Spawns `tmux -C attach -t `, parses `%output` notifications, + * decodes octal-escaped bytes, and broadcasts raw ANSI to subscribers. + * + * Design: + * - One ControlClient instance per tmux session (per-session, not per-server). + * - Subscribers register a callback; each raw Buffer chunk is broadcast. + * - On %exit or process close, all subscribers are notified and removed. + * + * Reference: feat/spike-tmux-cc / spike-cc.ts (Phase 0.5 PoC). + * + * Risk mitigations: + * R4: streaming line-parser, per-line decode, no full-buffer copies. + * + * Owner: T-1.1 + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { createInterface } from "node:readline"; + +export type OutputCallback = (chunk: Buffer) => void; +export type CloseCallback = (reason: string) => void; + +export interface ControlClientOptions { + session: string; + onClose?: CloseCallback; +} + +export class ControlClient { + readonly session: string; + private proc: ChildProcess | null = null; + private subscribers = new Map(); + private closed = false; + private onClose?: CloseCallback; + + constructor(opts: ControlClientOptions) { + this.session = opts.session; + this.onClose = opts.onClose; + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + start(): void { + if (this.proc) return; + this.closed = false; + + // -CC = control mode with passthrough (so tmux sends output events for all panes) + this.proc = spawn("tmux", ["-CC", "attach", "-t", this.session], { + stdio: ["pipe", "pipe", "pipe"], + }); + + const rl = createInterface({ + // biome-ignore lint/style/noNonNullAssertion: stdout is always set when stdio includes 'pipe' + input: this.proc.stdout!, + crlfDelay: Number.POSITIVE_INFINITY, + }); + + rl.on("line", (line: string) => { + this.parseLine(line); + }); + + this.proc.stderr?.on("data", (_d: Buffer) => { + // Ignore tmux stderr (status messages). Can log at debug level if needed. + }); + + this.proc.on("close", (code: number | null) => { + this.closed = true; + this.subscribers.clear(); + this.onClose?.(`tmux process exited (code=${code})`); + }); + } + + stop(): void { + if (this.proc && !this.closed) { + this.proc.kill("SIGTERM"); + } + } + + get isRunning(): boolean { + return !this.closed && this.proc !== null; + } + + // --------------------------------------------------------------------------- + // Subscriptions + // --------------------------------------------------------------------------- + + subscribe(cb: OutputCallback): () => void { + const key = Symbol(); + this.subscribers.set(key, cb); + return () => this.subscribers.delete(key); + } + + // --------------------------------------------------------------------------- + // Parsing + // --------------------------------------------------------------------------- + + /** + * Parse one line of tmux control-mode output. + * + * Control-mode lines that matter: + * %output % + * %exit [reason] + * Everything else is ignored. + */ + private parseLine(line: string): void { + if (!line.startsWith("%")) return; + + const spaceIdx = line.indexOf(" "); + const type = spaceIdx === -1 ? line.slice(1) : line.slice(1, spaceIdx); + const rest = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1); + + if (type === "output") { + this.handleOutput(rest); + } else if (type === "exit") { + this.closed = true; + this.subscribers.clear(); + this.onClose?.(`%exit ${rest}`); + } + // layout-change, window-add, etc. are ignored + } + + /** + * Handle a %output notification. + * Format: % + */ + private handleOutput(data: string): void { + const spaceIdx = data.indexOf(" "); + if (spaceIdx === -1) return; // malformed, skip + + const escapedValue = data.slice(spaceIdx + 1); + const decoded = decodeOctalEscapes(escapedValue); + if (decoded.length === 0) return; + + for (const cb of this.subscribers.values()) { + cb(decoded); + } + } +} + +// --------------------------------------------------------------------------- +// Octal-escape decoder (from spike-cc.ts, adapted) +// --------------------------------------------------------------------------- + +/** + * Decode tmux's octal-escaped output format. + * "hello\\012world" → Buffer containing "hello\nworld" + */ +export function decodeOctalEscapes(input: string): Buffer { + // Fast-path: nothing to decode + if (!input.includes("\\")) return Buffer.from(input, "binary"); + + const bytes: number[] = []; + let i = 0; + while (i < input.length) { + if (input[i] === "\\" && i + 3 < input.length) { + const oct = input.slice(i + 1, i + 4); + if (/^[0-7]{3}$/.test(oct)) { + bytes.push(parseInt(oct, 8)); + i += 4; + continue; + } + } + bytes.push(input.charCodeAt(i)); + i++; + } + return Buffer.from(bytes); +} diff --git a/extensions/remote-control/tmux/input.ts b/extensions/remote-control/tmux/input.ts new file mode 100644 index 0000000..7e877a8 --- /dev/null +++ b/extensions/remote-control/tmux/input.ts @@ -0,0 +1,89 @@ +/** + * tmux send-keys input translation. + * + * Translates IC-1 key names (and literal text) into tmux send-keys arguments. + * Used by the input route (T-1.5) to deliver keystrokes to a pane. + * + * Owner: T-1.1 + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** Named keys from IC-1 ClientToServer `{ type: "key"; name: string }`. */ +const KEY_MAP: Record = { + escape: "Escape", + tab: "Tab", + up: "Up", + down: "Down", + left: "Left", + right: "Right", + enter: "Enter", + "shift-enter": "S-Enter", + backspace: "BSpace", + "ctrl-c": "C-c", + "ctrl-d": "C-d", + "ctrl-z": "C-z", +}; + +/** + * Send a single named key to a tmux pane. + * Pane defaults to the first pane of the session (session:0.0). + */ +export async function sendKey( + session: string, + name: string, + pane = "0.0", +): Promise { + const tmuxKey = KEY_MAP[name.toLowerCase()]; + if (!tmuxKey) { + throw new Error( + `Unknown key name: "${name}". Supported: ${Object.keys(KEY_MAP).join(", ")}`, + ); + } + await execFileAsync("tmux", [ + "send-keys", + "-t", + `${session}:${pane}`, + tmuxKey, + ]); +} + +/** + * Send literal text to a tmux pane (IC-1 `{ type: "keys"; data: string }`). + * Uses send-keys -l which sends each character literally. + */ +export async function sendKeys( + session: string, + data: string, + pane = "0.0", +): Promise { + await execFileAsync("tmux", [ + "send-keys", + "-t", + `${session}:${pane}`, + "-l", + data, + ]); +} + +/** + * Send bracketed-paste to a tmux pane (IC-1 `{ type: "paste"; data: string }`). + * Wraps the data in bracketed-paste sequences then sends literally. + */ +export async function sendPaste( + session: string, + data: string, + pane = "0.0", +): Promise { + const wrapped = `\x1b[200~${data}\x1b[201~`; + await execFileAsync("tmux", [ + "send-keys", + "-t", + `${session}:${pane}`, + "-l", + wrapped, + ]); +} diff --git a/extensions/remote-control/tmux/manager.ts b/extensions/remote-control/tmux/manager.ts new file mode 100644 index 0000000..214131c --- /dev/null +++ b/extensions/remote-control/tmux/manager.ts @@ -0,0 +1,175 @@ +/** + * tmux session manager. + * + * Spawn, list, kill sessions and read metadata stored via tmux @description + * option. Checks tmux version at startup (requires >= 2.5). + * + * Owner: T-1.1 + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface TmuxSession { + id: string; // tmux session name (used as our ID) + name: string; // human name (same as id for now; T-1.4 may rename via @description) + description?: string; // from tmux @description option + createdAt: string; // ISO string, from tmux session_created_string + lastActivityAt: string; // ISO string, from tmux session_last_attached + width: number; + height: number; +} + +// --------------------------------------------------------------------------- +// Version guard +// --------------------------------------------------------------------------- + +let versionChecked = false; + +export async function checkTmuxVersion(): Promise { + if (versionChecked) return; + const { stdout } = await execFileAsync("tmux", ["-V"]); + const match = stdout.trim().match(/tmux (\d+)\.(\d+)/); + if (!match) throw new Error(`Cannot parse tmux version: ${stdout.trim()}`); + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + if (major < 2 || (major === 2 && minor < 5)) { + throw new Error( + `tmux >= 2.5 required (found ${stdout.trim()}). Upgrade tmux to use pi-remote-control.`, + ); + } + versionChecked = true; +} + +// --------------------------------------------------------------------------- +// Session CRUD +// --------------------------------------------------------------------------- + +/** + * Spawn a new detached tmux session. + * Returns the session name (used as stable ID). + */ +export async function spawnSession(opts: { + name: string; + width?: number; + height?: number; + command?: string; +}): Promise { + await checkTmuxVersion(); + const { name, width = 120, height = 40, command = "" } = opts; + + const args = [ + "new-session", + "-d", + "-s", + name, + "-x", + String(width), + "-y", + String(height), + ]; + if (command) args.push(command); + + await execFileAsync("tmux", args); + return name; +} + +/** + * List all tmux sessions with metadata. + */ +export async function listSessions(): Promise { + await checkTmuxVersion(); + + // Use a separator that's unlikely to appear in session names + const SEP = "\x1F"; // ASCII unit separator + const fmt = [ + "#{session_name}", + "#{session_created_string}", + "#{session_last_attached_string}", + "#{window_width}", + "#{window_height}", + ].join(SEP); + + let stdout: string; + try { + ({ stdout } = await execFileAsync("tmux", ["list-sessions", "-F", fmt])); + } catch (err: unknown) { + // tmux exits 1 when no sessions exist + if ( + err && + typeof err === "object" && + "code" in err && + (err as { code: number }).code === 1 + ) { + return []; + } + throw err; + } + + const sessions: TmuxSession[] = []; + for (const line of stdout.trim().split("\n")) { + if (!line) continue; + const [id, createdAt, lastActivityAt, w, h] = line.split(SEP); + + // Fetch @description option separately (may not be set) + let description: string | undefined; + try { + const r = await execFileAsync("tmux", [ + "show-options", + "-t", + id, + "-qv", + "@description", + ]); + const v = r.stdout.trim(); + if (v) description = v; + } catch { + // option not set — that's fine + } + + sessions.push({ + id, + name: id, + description, + createdAt, + lastActivityAt, + width: parseInt(w, 10) || 120, + height: parseInt(h, 10) || 40, + }); + } + return sessions; +} + +/** + * Get a single session by name. Returns null if not found. + */ +export async function getSession(name: string): Promise { + const all = await listSessions(); + return all.find((s) => s.id === name) ?? null; +} + +/** + * Kill a session. Throws if it doesn't exist. + */ +export async function killSession(name: string): Promise { + await checkTmuxVersion(); + await execFileAsync("tmux", ["kill-session", "-t", name]); +} + +/** + * Set the @description option on a session. + */ +export async function setDescription( + name: string, + description: string, +): Promise { + await execFileAsync("tmux", [ + "set-option", + "-t", + name, + "@description", + description, + ]); +} diff --git a/extensions/remote-control/tmux/snapshot.ts b/extensions/remote-control/tmux/snapshot.ts new file mode 100644 index 0000000..2e84a32 --- /dev/null +++ b/extensions/remote-control/tmux/snapshot.ts @@ -0,0 +1,57 @@ +/** + * tmux capture-pane snapshot. + * + * Returns a plain-text snapshot of a pane's visible content. + * Used by the snapshot route (T-1.5) and the /thumbnail endpoint (T-1.6). + * + * Owner: T-1.1 + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface SnapshotOptions { + /** tmux session name */ + session: string; + /** pane index within session (default "0.0") */ + pane?: string; + /** capture width (default: actual pane width) */ + width?: number; + /** capture height (default: actual pane height) */ + height?: number; + /** include escape sequences for colour/style (default: false = plain text) */ + escapes?: boolean; +} + +/** + * Capture a plain-text (or escape-annotated) snapshot of a tmux pane. + * Returns raw text as a string. + */ +export async function capturePane(opts: SnapshotOptions): Promise { + const { session, pane = "0.0", escapes = false } = opts; + const target = `${session}:${pane}`; + + const args = ["capture-pane", "-t", target, "-p"]; + if (escapes) args.push("-e"); // include escape sequences + // Note: -S/-E (start/end line) omitted — captures current visible content + + const { stdout } = await execFileAsync("tmux", args); + return stdout; +} + +/** + * Capture a thumbnail-sized snapshot (40×12) for the REST thumbnail endpoint. + * Returns plain text, trimmed. + */ +export async function captureThumbnail( + session: string, + pane = "0.0", +): Promise { + // tmux can't resize the capture directly via capture-pane flags, so we + // capture full content and truncate to 40-char wide × 12 lines. + const raw = await capturePane({ session, pane, escapes: false }); + const lines = raw.split("\n").slice(0, 12); + return lines.map((l) => l.slice(0, 40)).join("\n"); +}