feat(T-1.1): tmux manager, control-mode client, input, snapshot
This commit is contained in:
parent
4f6fa0e83b
commit
bd990a07ab
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* tmux control-mode client — per-session.
|
||||||
|
*
|
||||||
|
* Spawns `tmux -C attach -t <session>`, 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<symbol, OutputCallback>();
|
||||||
|
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 %<pane-id> <octal-escaped-bytes>
|
||||||
|
* %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: %<pane-id> <octal-escaped-value>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -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<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const wrapped = `\x1b[200~${data}\x1b[201~`;
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"send-keys",
|
||||||
|
"-t",
|
||||||
|
`${session}:${pane}`,
|
||||||
|
"-l",
|
||||||
|
wrapped,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<TmuxSession[]> {
|
||||||
|
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<TmuxSession | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
name,
|
||||||
|
"@description",
|
||||||
|
description,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue