diff --git a/server/src/index.ts b/server/src/index.ts index 589b2c5b..1491af87 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,10 +13,15 @@ import { DEFAULT_PORT } from "kolu-common/config"; import { appRouter } from "./router.ts"; import { log } from "./log.ts"; import { initSessionAutoSave } from "./session.ts"; -import { snapshotSession } from "./terminals.ts"; +import { + snapshotSession, + listTerminals, + listTerminalsWithPaneIndex, +} from "./terminals.ts"; import { resolveTlsOptions } from "./tls.ts"; import { configureNixShellEnv } from "./shell.ts"; import { serverHostname } from "./hostname.ts"; +import { initTmuxShim, cleanupTmuxShim } from "./tmux-env.ts"; import pkg from "../package.json" with { type: "json" }; const argv = cli({ @@ -62,6 +67,9 @@ const argv = cli({ configureNixShellEnv(argv.flags.allowNixShellWithEnvWhitelist); initSessionAutoSave(snapshotSession); +initTmuxShim(); +// Expose server port to PTY shells so the tmux shim can find the server +process.env.KOLU_PORT = String(argv.flags.port); if (argv.flags.verbose) log.level = "debug"; const app = new Hono(); @@ -116,6 +124,7 @@ app.use("/rpc/*", async (c, next) => { for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { process.on(sig, () => { log.info({ signal: sig }, "shutting down"); + cleanupTmuxShim(); process.exit(0); }); } @@ -131,6 +140,10 @@ process.on("unhandledRejection", (reason) => { // --- Health endpoint --- app.get("/api/health", (c) => c.text("kolu")); +// --- Terminal list snapshot (non-streaming, for tmux shim) --- +// Includes tmuxPaneIndex so the shim can map %N pane IDs to terminal UUIDs. +app.get("/api/terminals", (c) => c.json(listTerminalsWithPaneIndex())); + // --- Dynamic PWA manifest (includes hostname) --- // theme_color must match in client/index.html app.get("/manifest.webmanifest", (c) => { diff --git a/server/src/pty.ts b/server/src/pty.ts index 7eac6b4a..0ddba57f 100644 --- a/server/src/pty.ts +++ b/server/src/pty.ts @@ -9,6 +9,7 @@ import * as pty from "node-pty"; import { createRequire } from "node:module"; import { DEFAULT_COLS, + DEFAULT_PORT, DEFAULT_ROWS, DEFAULT_SCROLLBACK, } from "kolu-common/config"; @@ -48,17 +49,25 @@ export function spawnPty( onCwd?: (cwd: string) => void; }, clipboard: { shimBinDir: string; clipboardDir: string }, + tmux: { shimBinDir: string; tmuxEnv: string; paneId: string }, spawnCwd?: string, ): PtyHandle { const env = cleanEnv(); const shell = env.SHELL ?? "/bin/sh"; const cwd = spawnCwd || env.HOME || "/"; - // Inject clipboard shim dir into shell rc AFTER the user's rc — + // Inject shim dirs into shell rc AFTER the user's rc — // NixOS rebuilds PATH during shell init, so env-level PATH gets lost. - const osc7 = osc7Init(shell, env.HOME, clipboard.shimBinDir); + const extraPath = [clipboard.shimBinDir, tmux.shimBinDir] + .filter(Boolean) + .join(":"); + const osc7 = osc7Init(shell, env.HOME, extraPath); Object.assign(env, osc7.env); env.KOLU_CLIPBOARD_DIR = clipboard.clipboardDir; + // tmux compatibility: Claude Code detects multiplexer via $TMUX and uses $TMUX_PANE as self-identity + env.TMUX = tmux.tmuxEnv; + env.TMUX_PANE = tmux.paneId; + env.KOLU_PORT = process.env.KOLU_PORT ?? String(DEFAULT_PORT); tlog.info({ shell, cwd }, "spawning pty"); const proc = pty.spawn(shell, osc7.args, { diff --git a/server/src/terminals.ts b/server/src/terminals.ts index 75953386..79e2f18c 100644 --- a/server/src/terminals.ts +++ b/server/src/terminals.ts @@ -19,6 +19,7 @@ import { createClipboardDir, cleanupClipboardDir, } from "./clipboard.ts"; +import { getTmuxShimDir, allocatePaneIndex, tmuxEnvValue } from "./tmux-env.ts"; import { createMetadata, updateMetadata, @@ -44,6 +45,8 @@ export interface TerminalProcess { clipboardDir: string; /** Cleanup function for all metadata providers. */ stopProviders: () => void; + /** Synthetic tmux pane index (%N in $TMUX_PANE). Monotonic, never reused. */ + tmuxPaneIndex: number; } const terminals = new Map(); @@ -129,6 +132,7 @@ export function createTerminal(cwd?: string, parentId?: string): TerminalInfo { const tlog = log.child({ terminal: id }); const clipboardDir = createClipboardDir(id); + const paneIndex = allocatePaneIndex(); const handle = spawnPty( tlog, { @@ -168,6 +172,11 @@ export function createTerminal(cwd?: string, parentId?: string): TerminalInfo { }, }, { shimBinDir: CLIPBOARD_SHIM_DIR, clipboardDir }, + { + shimBinDir: getTmuxShimDir(), + tmuxEnv: tmuxEnvValue(), + paneId: `%${paneIndex}`, + }, cwd, ); @@ -184,6 +193,7 @@ export function createTerminal(cwd?: string, parentId?: string): TerminalInfo { activityHistory: [[Date.now(), true] as ActivitySample], clipboardDir, stopProviders: () => {}, + tmuxPaneIndex: paneIndex, }; // Start providers after entry is in the map (providers may emit immediately) terminals.set(id, entry); @@ -203,6 +213,15 @@ export function listTerminals(): TerminalInfo[] { return list; } +/** Terminal list with tmux pane indices — used by the tmux shim's /api/terminals endpoint. */ +export function listTerminalsWithPaneIndex(): (TerminalInfo & { + tmuxPaneIndex: number; +})[] { + return [...terminals.values()] + .sort((a, b) => a.info.meta.sortOrder - b.info.meta.sortOrder) + .map((entry) => ({ ...entry.info, tmuxPaneIndex: entry.tmuxPaneIndex })); +} + export function getTerminal(id: TerminalId): TerminalProcess | undefined { return terminals.get(id); } diff --git a/server/src/tmux-env.ts b/server/src/tmux-env.ts new file mode 100644 index 00000000..8a2620ca --- /dev/null +++ b/server/src/tmux-env.ts @@ -0,0 +1,82 @@ +/** + * tmux compatibility environment for PTY shells. + * + * Creates a temporary directory containing a `tmux` wrapper script + * that invokes the kolu-tmux shim. This directory is prepended to + * PTY shell PATH so Claude Code (and other AI tools) find `tmux` + * and auto-detect multiplexer support. + * + * Also manages per-terminal synthetic pane IDs ($TMUX_PANE) and + * the $TMUX env var that signals "inside tmux" to tools. + */ + +import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Path to tmux-shim.ts (co-located with this module). */ +const SHIM_SCRIPT = join(__dirname, "tmux-shim.ts"); + +/** Resolve absolute path to tsx at startup — PTY shells may not have it on PATH + * because NixOS shell init rebuilds PATH from scratch. */ +function resolveTsxPath(): string { + try { + return execFileSync("which", ["tsx"], { encoding: "utf-8" }).trim(); + } catch { + // Fallback: assume tsx is on PATH (production Nix wrapper guarantees it) + return "tsx"; + } +} + +/** Temp directory containing the `tmux` wrapper. Created once at startup. */ +let shimBinDir: string | undefined; + +/** Monotonically increasing pane index counter. Never reused — keeps IDs stable. */ +let nextPaneIndex = 0; + +/** Create the tmux shim bin directory. Call once at server startup. */ +export function initTmuxShim(): void { + const dir = join(tmpdir(), `kolu-tmux-shim-${process.pid}`); + mkdirSync(dir, { recursive: true }); + + // Write a tiny wrapper script that delegates to tsx + tmux-shim.ts. + // Use absolute tsx path — PTY shells may not have it on PATH after shell init. + const tsxPath = resolveTsxPath(); + const wrapper = join(dir, "tmux"); + writeFileSync( + wrapper, + `#!/bin/sh\nexec "${tsxPath}" "${SHIM_SCRIPT}" "$@"\n`, + ); + chmodSync(wrapper, 0o755); + + shimBinDir = dir; +} + +/** Get the tmux shim bin directory. Throws if initTmuxShim() hasn't been called. */ +export function getTmuxShimDir(): string { + if (!shimBinDir) throw new Error("initTmuxShim() not called"); + return shimBinDir; +} + +/** Remove the temp directory on shutdown. */ +export function cleanupTmuxShim(): void { + if (shimBinDir) { + rmSync(shimBinDir, { recursive: true, force: true }); + shimBinDir = undefined; + } +} + +/** Allocate a unique pane index for a new terminal. Returns the index (0, 1, 2, ...). */ +export function allocatePaneIndex(): number { + return nextPaneIndex++; +} + +/** Build the $TMUX value for PTY shells. Format matches tmux: ,,0 */ +export function tmuxEnvValue(): string { + const socketPath = join(tmpdir(), `kolu-tmux-${process.pid}`, "default"); + return `${socketPath},${process.pid},0`; +} diff --git a/server/src/tmux-shim.ts b/server/src/tmux-shim.ts new file mode 100644 index 00000000..ea19cd27 --- /dev/null +++ b/server/src/tmux-shim.ts @@ -0,0 +1,810 @@ +#!/usr/bin/env node +/** + * kolu-tmux: tmux compatibility shim for AI tool integration. + * + * Translates the subset of tmux commands used by Claude Code's TmuxBackend + * into Kolu HTTP RPC calls. Enables Claude Code teammate/swarm features + * inside Kolu terminals with zero configuration. + * + * Environment: + * KOLU_PORT — Kolu server port (default: 7681) + * TMUX_PANE — Synthetic pane ID for the calling terminal (e.g. %0) + */ + +import { existsSync, writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// --- Config --- + +const KOLU_PORT = process.env.KOLU_PORT || "7681"; +const BASE_URL = `http://127.0.0.1:${KOLU_PORT}`; +const FAKE_VERSION = "kolu-tmux 3.4"; +const SESSION_NAME = "kolu"; + +// --- RPC helpers --- + +async function rpc( + method: string, + input?: Record, +): Promise { + const url = `${BASE_URL}/rpc/${method}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ json: input ?? {} }), + }); + if (!res.ok) { + throw new Error(`RPC ${method} failed: ${res.status} ${await res.text()}`); + } + const body = await res.json(); + return body.json as T; +} + +interface TerminalInfo { + id: string; + pid: number; + /** Synthetic tmux pane index (%N in $TMUX_PANE). Monotonic, never reused. */ + tmuxPaneIndex: number; + meta: { + cwd: string; + parentId?: string; + sortOrder: number; + git: { repoName: string; branch: string } | null; + themeName?: string; + }; +} + +/** Fetch all terminals from the server (non-streaming snapshot endpoint). */ +async function listAllTerminals(): Promise { + const url = `${BASE_URL}/api/terminals`; + const res = await fetch(url); + if (!res.ok) throw new Error(`list failed: ${res.status}`); + return (await res.json()) as TerminalInfo[]; +} + +// --- Terminal list helpers --- + +/** Top-level terminals (no parent) sorted by sortOrder. These map to tmux "windows". */ +function topLevel(all: TerminalInfo[]): TerminalInfo[] { + return all + .filter((t) => !t.meta.parentId) + .sort((a, b) => a.meta.sortOrder - b.meta.sortOrder); +} + +/** Children of a given parent, sorted by sortOrder. These map to tmux "panes". */ +function children(all: TerminalInfo[], parentId: string): TerminalInfo[] { + return all + .filter((t) => t.meta.parentId === parentId) + .sort((a, b) => a.meta.sortOrder - b.meta.sortOrder); +} + +/** Build pane index → terminal map using the server-assigned tmuxPaneIndex. */ +function buildPaneMap(all: TerminalInfo[]): Map { + const map = new Map(); + for (const t of all) { + map.set(t.tmuxPaneIndex, t); + } + return map; +} + +/** Resolve %N pane ID to terminal UUID. */ +function paneIdToIndex(paneId: string): number { + if (paneId.startsWith("%")) return parseInt(paneId.slice(1), 10); + return parseInt(paneId, 10); +} + +/** Resolve a tmux target (-t) to a terminal. Supports %N, bare index, session:window.pane. */ +function resolveTarget( + target: string | undefined, + all: TerminalInfo[], +): TerminalInfo | undefined { + const paneMap = buildPaneMap(all); + + if (!target) { + // Use $TMUX_PANE from env — fail if unset (every Kolu PTY has TMUX_PANE) + const envPane = process.env.TMUX_PANE; + if (envPane) { + return paneMap.get(paneIdToIndex(envPane)); + } + return undefined; + } + + // %N pane ID + if (target.startsWith("%")) { + return paneMap.get(paneIdToIndex(target)); + } + + // session:window.pane format + const dotIdx = target.lastIndexOf("."); + if (dotIdx !== -1) { + const paneStr = target.slice(dotIdx + 1); + if (paneStr.startsWith("%")) { + return paneMap.get(paneIdToIndex(paneStr)); + } + // Numeric pane within a window + const colonIdx = target.indexOf(":"); + const windowStr = + colonIdx !== -1 + ? target.slice(colonIdx + 1, dotIdx) + : target.slice(0, dotIdx); + const windowIdx = parseInt(windowStr, 10); + const paneIdx = parseInt(paneStr, 10); + const tops = topLevel(all); + const win = tops[windowIdx]; + if (!win) return undefined; + const panes = [win, ...children(all, win.id)]; + return panes[paneIdx]; + } + + // session:window format (target the window itself = first pane) + const colonIdx = target.indexOf(":"); + if (colonIdx !== -1) { + const windowStr = target.slice(colonIdx + 1); + const windowIdx = parseInt(windowStr, 10); + const tops = topLevel(all); + return tops[windowIdx]; + } + + // Bare numeric index — treat as pane index + const idx = parseInt(target, 10); + if (!isNaN(idx)) return paneMap.get(idx); + + return undefined; +} + +// --- Format string evaluator --- + +function evalFormat( + fmt: string, + terminal: TerminalInfo, + all: TerminalInfo[], +): string { + const paneMap = buildPaneMap(all); + const tops = topLevel(all); + + const paneIndex = terminal.tmuxPaneIndex; + + // Find window index (parent's position in top-level, or own position if top-level) + const parentId = terminal.meta.parentId; + const windowTerminal = parentId + ? all.find((t) => t.id === parentId) + : terminal; + const windowIndex = windowTerminal + ? tops.findIndex((t) => t.id === windowTerminal.id) + : 0; + + // Is this the active pane? (first in its window) + const windowId = parentId || terminal.id; + const windowPanes = [ + all.find((t) => t.id === windowId), + ...children(all, windowId), + ].filter(Boolean) as TerminalInfo[]; + const isActive = windowPanes[0]?.id === terminal.id ? "1" : "0"; + + const vars: Record = { + session_name: SESSION_NAME, + session_id: "$0", + window_index: String(Math.max(0, windowIndex)), + window_id: `@${Math.max(0, windowIndex)}`, + window_name: + terminal.meta.git?.repoName || + terminal.meta.cwd.split("/").pop() || + "shell", + window_active: isActive, + window_width: "80", + window_height: "24", + pane_id: `%${paneIndex}`, + pane_index: String(paneIndex), + pane_pid: String(terminal.pid), + pane_current_path: terminal.meta.cwd, + pane_active: isActive, + pane_width: "80", + pane_height: "24", + pane_title: terminal.meta.cwd.split("/").pop() || "shell", + pane_current_command: "bash", + window_panes: String(windowPanes.length), + }; + + return fmt.replace(/#\{([^}]+)\}/g, (_match, varName: string) => { + return vars[varName] ?? ""; + }); +} + +// --- Argument parsing --- + +/** Flags that take a subsequent value argument (shared across all tmux subcommands). */ +const VALUED_FLAGS = new Set([ + "-t", + "-F", + "-c", + "-s", + "-n", + "-x", + "-y", + "-S", + "-E", + "-L", +]); + +function parseArgs(args: string[]): { + flags: Record; + positional: string[]; +} { + const flags: Record = {}; + const positional: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--") { + positional.push(...args.slice(i + 1)); + break; + } + if (arg.startsWith("-") && arg.length > 1) { + // Handle combined short flags like -hp + if (arg.length > 2 && !arg.startsWith("--") && !VALUED_FLAGS.has(arg)) { + // Split into individual flags, last one may take a value + for (let j = 1; j < arg.length; j++) { + const flag = `-${arg[j]}`; + if ( + j === arg.length - 1 && + VALUED_FLAGS.has(flag) && + i + 1 < args.length + ) { + flags[flag] = args[++i]!; + } else { + flags[flag] = true; + } + } + continue; + } + if (VALUED_FLAGS.has(arg) && i + 1 < args.length) { + flags[arg] = args[++i]!; + } else { + flags[arg] = true; + } + } else { + positional.push(arg); + } + } + return { flags, positional }; +} + +// --- Command handlers --- + +async function cmdVersion(): Promise { + console.log(FAKE_VERSION); +} + +async function cmdHasSession(_args: string[]): Promise { + // Always succeeds — Kolu is always "in session" + try { + const res = await fetch(`${BASE_URL}/api/health`); + if (!res.ok) process.exit(1); + } catch { + process.exit(1); + } +} + +async function cmdListSessions(args: string[]): Promise { + const { flags } = parseArgs(args); + const fmt = flags["-F"] as string | undefined; + + if (fmt) { + const all = await listAllTerminals(); + // Create a dummy terminal for format evaluation at session level + const first = all[0]; + if (first) { + console.log(evalFormat(fmt, first, all)); + } else { + console.log( + evalFormat( + fmt, + { + id: "", + pid: 0, + tmuxPaneIndex: 0, + meta: { cwd: "/", sortOrder: 0, git: null }, + }, + [], + ), + ); + } + } else { + console.log( + `${SESSION_NAME}: 1 windows (created Mon Jan 1 00:00:00 2024)`, + ); + } +} + +async function cmdListWindows(args: string[]): Promise { + const { flags } = parseArgs(args); + const fmt = flags["-F"] as string | undefined; + const all = await listAllTerminals(); + const tops = topLevel(all); + + for (const win of tops) { + if (fmt) { + console.log(evalFormat(fmt, win, all)); + } else { + const idx = tops.indexOf(win); + const name = + win.meta.git?.repoName || win.meta.cwd.split("/").pop() || "shell"; + console.log(`${idx}: ${name} (1 panes)`); + } + } +} + +async function cmdListPanes(args: string[]): Promise { + const { flags } = parseArgs(args); + const fmt = flags["-F"] as string | undefined; + const target = flags["-t"] as string | undefined; + const allFlag = flags["-a"]; + const all = await listAllTerminals(); + + let panes: TerminalInfo[]; + + if (allFlag) { + // List all panes across all windows + panes = [...all].sort((a, b) => a.meta.sortOrder - b.meta.sortOrder); + } else if (target) { + // List panes in the targeted window + const win = resolveTarget(target, all); + if (!win) { + process.stderr.write(`can't find window: ${target}\n`); + process.exit(1); + } + const winId = win.meta.parentId || win.id; + panes = [all.find((t) => t.id === winId), ...children(all, winId)].filter( + (t): t is TerminalInfo => t != null, + ); + } else { + // List panes in the current window (from $TMUX_PANE) + const current = resolveTarget(undefined, all); + if (current) { + const winId = current.meta.parentId || current.id; + panes = [all.find((t) => t.id === winId), ...children(all, winId)].filter( + (t): t is TerminalInfo => t != null, + ); + } else { + panes = all; + } + } + + for (const pane of panes) { + if (fmt) { + console.log(evalFormat(fmt, pane, all)); + } else { + console.log(`%${pane.tmuxPaneIndex}: [80x24] ${pane.meta.cwd}`); + } + } +} + +async function cmdSplitWindow(args: string[]): Promise { + const { flags } = parseArgs(args); + const target = flags["-t"] as string | undefined; + const cwd = flags["-c"] as string | undefined; + const printFmt = flags["-F"] as string | undefined; + const printInfo = flags["-P"]; // -P = print new pane info + // -h, -v, -b, -d are accepted but ignored (Kolu sub-terminals are tabs, not spatial splits) + + const all = await listAllTerminals(); + + // Determine parent: explicit target, or $TMUX_PANE + let parentId: string | undefined; + if (target) { + const parent = resolveTarget(target, all); + if (parent) { + // If target is a child, use its parent (split within same window) + parentId = parent.meta.parentId || parent.id; + } + } else { + const current = resolveTarget(undefined, all); + if (current) { + parentId = current.meta.parentId || current.id; + } + } + + const created = await rpc("terminal/create", { + cwd: cwd || undefined, + parentId, + }); + + if (printFmt || printInfo) { + const updatedAll = await listAllTerminals(); + const fmt = printFmt || "#{session_name}:#{window_index}.#{pane_index}"; + console.log(evalFormat(fmt, created, updatedAll)); + } +} + +async function cmdSendKeys(args: string[]): Promise { + const { flags, positional } = parseArgs(args); + const target = flags["-t"] as string | undefined; + const literal = flags["-l"]; + + const all = await listAllTerminals(); + const term = resolveTarget(target, all); + if (!term) { + process.stderr.write(`can't find pane: ${target || "$TMUX_PANE"}\n`); + process.exit(1); + } + + let data: string; + if (literal) { + data = positional.join(" "); + } else { + // Special key map (case-insensitive matching, per tmux spec) + const specialKey = (token: string): string | undefined => { + switch (token.toLowerCase()) { + case "enter": + case "c-m": + case "kpenter": + return "\r"; + case "tab": + case "c-i": + return "\t"; + case "space": + return " "; + case "bspace": + case "backspace": + return "\x7f"; + case "escape": + case "esc": + case "c-[": + return "\x1b"; + case "c-c": + return "\x03"; + case "c-d": + return "\x04"; + case "c-z": + return "\x1a"; + case "c-l": + return "\x0c"; + default: + return undefined; + } + }; + + // Non-special tokens are joined with spaces; special keys consume surrounding spaces + let result = ""; + let pendingSpace = false; + for (const token of positional) { + const special = specialKey(token); + if (special !== undefined) { + result += special; + pendingSpace = false; + continue; + } + if (pendingSpace) result += " "; + result += token; + pendingSpace = true; + } + data = result; + } + + await rpc("terminal/sendInput", { id: term.id, data }); +} + +async function cmdCapturePane(args: string[]): Promise { + const { flags } = parseArgs(args); + const target = flags["-t"] as string | undefined; + const startLine = flags["-S"] as string | undefined; + const endLine = flags["-E"] as string | undefined; + // -p flag means print to stdout (always do this) + // -J flag means join wrapped lines (we always do this) + + const all = await listAllTerminals(); + const term = resolveTarget(target, all); + if (!term) { + process.stderr.write(`can't find pane: ${target || "$TMUX_PANE"}\n`); + process.exit(1); + } + + const input: Record = { id: term.id }; + if (startLine !== undefined) { + const n = parseInt(startLine, 10); + if (!isNaN(n)) input.startLine = Math.max(0, n); + } + if (endLine !== undefined) { + const n = parseInt(endLine, 10); + if (!isNaN(n)) input.endLine = n; + } + + const text = await rpc("terminal/screenText", input); + console.log(text); +} + +async function cmdKillPane(args: string[]): Promise { + const { flags } = parseArgs(args); + const target = flags["-t"] as string | undefined; + + const all = await listAllTerminals(); + const term = resolveTarget(target, all); + if (!term) { + process.stderr.write(`can't find pane: ${target || "$TMUX_PANE"}\n`); + process.exit(1); + } + + await rpc("terminal/kill", { id: term.id }); +} + +async function cmdDisplayMessage(args: string[]): Promise { + const { flags, positional } = parseArgs(args); + const target = flags["-t"] as string | undefined; + // -p means print to stdout — we always do, so it's accepted but not checked + const fmt = positional[0] || (flags["-F"] as string) || ""; + + const all = await listAllTerminals(); + const term = resolveTarget(target, all); + if (!term) { + // Fallback: use first terminal + const first = all[0]; + if (first) { + console.log(evalFormat(fmt, first, all)); + } + return; + } + + console.log(evalFormat(fmt, term, all)); +} + +async function cmdNewSession(args: string[]): Promise { + const { flags } = parseArgs(args); + const cwd = flags["-c"] as string | undefined; + // -s (session name) is ignored — Kolu is always one session + + await rpc("terminal/create", { cwd: cwd || undefined }); + console.log(SESSION_NAME); +} + +async function cmdNewWindow(args: string[]): Promise { + const { flags } = parseArgs(args); + const cwd = flags["-c"] as string | undefined; + const printFmt = flags["-F"] as string | undefined; + // -t (target session) is ignored — single session + + const created = await rpc("terminal/create", { + cwd: cwd || undefined, + }); + + if (printFmt) { + const all = await listAllTerminals(); + console.log(evalFormat(printFmt, created, all)); + } +} + +async function cmdResizePane(args: string[]): Promise { + const { flags } = parseArgs(args); + const target = flags["-t"] as string | undefined; + const width = flags["-x"] as string | undefined; + const height = flags["-y"] as string | undefined; + + if (!width && !height) return; // Nothing to resize + + const all = await listAllTerminals(); + const term = resolveTarget(target, all); + if (!term) return; + + await rpc("terminal/resize", { + id: term.id, + cols: width ? parseInt(width, 10) : 80, + rows: height ? parseInt(height, 10) : 24, + }); +} + +async function cmdSelectPane(args: string[]): Promise { + // Claude Code uses select-pane with -P "bg=..." for styling — no-op in Kolu +} + +async function cmdBreakPane(args: string[]): Promise { + const { flags } = parseArgs(args); + const target = flags["-t"] as string | undefined; + + const all = await listAllTerminals(); + const term = resolveTarget(target, all); + if (!term) return; + + // break-pane = promote to top-level (clear parent) + await rpc("terminal/setParent", { id: term.id, parentId: null }); +} + +async function cmdJoinPane(args: string[]): Promise { + const { flags } = parseArgs(args); + const source = flags["-s"] as string | undefined; + const target = flags["-t"] as string | undefined; + + const all = await listAllTerminals(); + const srcTerm = resolveTarget(source, all); + const dstTerm = resolveTarget(target, all); + if (!srcTerm || !dstTerm) return; + + // join-pane = make source a child of target's window + const parentId = dstTerm.meta.parentId || dstTerm.id; + await rpc("terminal/setParent", { id: srcTerm.id, parentId }); +} + +// --- wait-for (file-based inter-process signaling) --- + +function waitForSignalPath(name: string): string { + const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, "_"); + return join(tmpdir(), `kolu-wait-for-${sanitized}.sig`); +} + +async function cmdWaitFor(args: string[]): Promise { + const { flags, positional } = parseArgs(args); + const name = positional.find((p) => !p.startsWith("-")); + if (!name) { + process.stderr.write("wait-for requires a name\n"); + process.exit(1); + } + + const signalPath = waitForSignalPath(name); + + if (flags["-S"]) { + // Signal mode: create the file + writeFileSync(signalPath, ""); + return; + } + + // Wait mode: poll for the file (30s timeout) + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (existsSync(signalPath)) { + try { + unlinkSync(signalPath); + } catch {} + return; + } + await new Promise((r) => setTimeout(r, 50)); + } + process.stderr.write(`wait-for timeout: ${name}\n`); + process.exit(1); +} + +// --- No-op commands --- + +function noOp(): void { + // Silently succeed +} + +// --- Main dispatch --- + +async function main(): Promise { + const args = process.argv.slice(2); + + // Handle global flags before subcommand + let i = 0; + while (i < args.length) { + if (args[i] === "-L" || args[i] === "-S") { + // Socket name/path — accept and ignore + i += 2; + continue; + } + if (args[i] === "-f") { + // Config file — ignore + i += 2; + continue; + } + if (args[i] === "-V") { + await cmdVersion(); + return; + } + break; + } + + const subcommand = args[i]; + const subArgs = args.slice(i + 1); + + switch (subcommand) { + case "has-session": + await cmdHasSession(subArgs); + break; + case "list-sessions": + case "ls": + await cmdListSessions(subArgs); + break; + case "list-windows": + case "lsw": + await cmdListWindows(subArgs); + break; + case "list-panes": + case "lsp": + await cmdListPanes(subArgs); + break; + case "split-window": + case "splitw": + await cmdSplitWindow(subArgs); + break; + case "send-keys": + case "send": + await cmdSendKeys(subArgs); + break; + case "capture-pane": + case "capturep": + await cmdCapturePane(subArgs); + break; + case "kill-pane": + case "killp": + await cmdKillPane(subArgs); + break; + case "display-message": + case "display": + await cmdDisplayMessage(subArgs); + break; + case "new-session": + case "new": + await cmdNewSession(subArgs); + break; + case "new-window": + case "neww": + await cmdNewWindow(subArgs); + break; + case "resize-pane": + case "resizep": + await cmdResizePane(subArgs); + break; + case "select-pane": + case "selectp": + await cmdSelectPane(subArgs); + break; + case "break-pane": + case "breakp": + await cmdBreakPane(subArgs); + break; + case "join-pane": + case "joinp": + await cmdJoinPane(subArgs); + break; + case "select-layout": + case "selectl": + noOp(); + break; + case "set-option": + case "set": + case "set-window-option": + case "setw": + noOp(); + break; + case "show-options": + case "show": + // Return synthetic prefix if asked + if (subArgs.includes("prefix")) { + console.log("C-b"); + } + break; + case "wait-for": + await cmdWaitFor(subArgs); + break; + // Additional no-ops (accepted silently per cmux compat) + case "source-file": + case "refresh-client": + case "attach-session": + case "detach-client": + case "last-window": + case "next-window": + case "previous-window": + case "set-hook": + case "set-buffer": + case "list-buffers": + case "rename-window": + case "renamew": + case "kill-window": + case "killw": + noOp(); + break; + default: + if (!subcommand) { + // No subcommand = just `tmux` — print version like real tmux does on error + process.stderr.write(`${FAKE_VERSION}\n`); + process.exit(1); + } + // Unknown command — no-op with warning + process.stderr.write(`kolu-tmux: unknown command: ${subcommand}\n`); + process.exit(1); + } +} + +main().catch((err) => { + process.stderr.write(`kolu-tmux: ${err.message}\n`); + process.exit(1); +}); diff --git a/tests/features/tmux-shim.feature b/tests/features/tmux-shim.feature new file mode 100644 index 00000000..fed4a7b0 --- /dev/null +++ b/tests/features/tmux-shim.feature @@ -0,0 +1,48 @@ +Feature: tmux compatibility shim + The kolu-tmux shim translates tmux commands into Kolu RPC calls, + enabling Claude Code agent teams inside Kolu terminals. + + Background: + Given the terminal is ready + + Scenario: Terminal list snapshot API + Then the /api/terminals endpoint should return a JSON array with at least 1 terminal + + Scenario: Terminals have tmux environment variables + When I run "echo TMUX=$TMUX" + Then the screen state should contain "TMUX=" + And the screen state should not contain "TMUX=$TMUX" + When I run "echo PANE=$TMUX_PANE" + Then the screen state should contain "PANE=%" + + Scenario: tmux shim is on PATH + When I run "which tmux" + Then the screen state should contain "kolu-tmux-shim" + + Scenario: tmux -V returns version + When I run "tmux -V" + Then the screen state should contain "kolu-tmux 3.4" + + Scenario: tmux has-session succeeds + When I run "tmux has-session -t kolu; echo exit=$?" + Then the screen state should contain "exit=0" + + Scenario: tmux list-panes returns pane info + When I run "tmux list-panes -F '#{pane_id} #{pane_current_path}'" + Then the screen state should contain "%" + + Scenario: tmux display-message shows pane ID + When I run "tmux display-message -p '#{pane_id}'" + Then the screen state should contain "%" + + Scenario: tmux split-window creates a sub-terminal + When I create a sub-terminal via the tmux shim + Then the /api/terminals endpoint should return a JSON array with at least 2 terminal + And there should be no page errors + + Scenario: tmux capture-pane reads terminal buffer + When I run "echo cap-test-xyz" + And the screen state should contain "cap-test-xyz" + And I capture the current pane via the tmux shim + Then the captured text should contain "cap-test-xyz" + And there should be no page errors diff --git a/tests/step_definitions/tmux_shim_steps.ts b/tests/step_definitions/tmux_shim_steps.ts new file mode 100644 index 00000000..325929c0 --- /dev/null +++ b/tests/step_definitions/tmux_shim_steps.ts @@ -0,0 +1,127 @@ +import { Then, When } from "@cucumber/cucumber"; +import { KoluWorld } from "../support/world.ts"; +import { pollUntilBufferContains } from "../support/buffer.ts"; +import * as assert from "node:assert"; + +Then( + "the \\/api\\/terminals endpoint should return a JSON array with at least {int} terminal", + async function (this: KoluWorld, minCount: number) { + const resp = await this.page.request.fetch("/api/terminals"); + assert.ok(resp.ok(), `GET /api/terminals failed: ${resp.status()}`); + const body = await resp.json(); + assert.ok(Array.isArray(body), "Expected JSON array"); + assert.ok( + body.length >= minCount, + `Expected at least ${minCount} terminals, got ${body.length}`, + ); + // Verify shape + const first = body[0]; + assert.ok(first.id, "Terminal should have an id"); + assert.ok(first.pid, "Terminal should have a pid"); + assert.ok(first.meta, "Terminal should have meta"); + }, +); + +Then( + "the screen state should not contain {string}", + async function (this: KoluWorld, unexpected: string) { + // Small delay to let terminal output render + await this.waitForFrame(); + const content = await this.page.evaluate(() => { + const el = document.querySelector("[data-visible] .xterm-screen"); + return el?.textContent ?? ""; + }); + assert.ok( + !content.includes(unexpected), + `Screen state should NOT contain "${unexpected}" but it does`, + ); + }, +); + +// --- tmux shim integration steps --- + +When( + "I create a sub-terminal via the tmux shim", + async function (this: KoluWorld) { + // Use tmux split-window -P -F to create and get the pane ID + await this.terminalRun( + "NEW_PANE=$(tmux split-window -h -P -F '#{pane_id}'); echo NEWPANE=$NEW_PANE", + ); + await pollUntilBufferContains(this.page, "NEWPANE=%"); + }, +); + +When( + "I send keys {string} via the tmux shim to the new pane", + async function (this: KoluWorld, keys: string) { + const resp = await this.page.request.fetch("/api/terminals"); + const terminals = await resp.json(); + const last = terminals[terminals.length - 1]; + await this.terminalRun( + `tmux send-keys -t %${last.tmuxPaneIndex} -l '${keys}'`, + ); + }, +); + +When( + "I send key Enter via the tmux shim to the new pane", + async function (this: KoluWorld) { + const resp = await this.page.request.fetch("/api/terminals"); + const terminals = await resp.json(); + const last = terminals[terminals.length - 1]; + await this.terminalRun(`tmux send-keys -t %${last.tmuxPaneIndex} Enter`); + }, +); + +When( + "I wait {int} second(s)", + async function (this: KoluWorld, seconds: number) { + await new Promise((r) => setTimeout(r, seconds * 1000)); + }, +); + +When( + "I capture the current pane via the tmux shim", + async function (this: KoluWorld) { + // Get the current terminal's pane index from the API + const resp = await this.page.request.fetch("/api/terminals"); + const terminals = await resp.json(); + // The visible terminal is the first one (Background step creates it) + const paneIdx = terminals[0].tmuxPaneIndex; + // Use the HTTP API directly as a simpler test of screen capture + const capResp = await this.page.request.fetch("/rpc/terminal/screenText", { + method: "POST", + headers: { "Content-Type": "application/json" }, + data: JSON.stringify({ json: { id: terminals[0].id } }), + }); + const capBody = await capResp.json(); + const capText = (capBody.json ?? capBody) as string; + const fs = await import("node:fs/promises"); + await fs.writeFile("/tmp/kolu-cap.txt", capText); + }, +); + +When( + "I capture the new pane via the tmux shim", + async function (this: KoluWorld) { + const resp = await this.page.request.fetch("/api/terminals"); + const terminals = await resp.json(); + const last = terminals[terminals.length - 1]; + await this.terminalRun( + `tmux capture-pane -p -t %${last.tmuxPaneIndex} > /tmp/kolu-tmux-capture-test.txt; echo CAPTURED`, + ); + await pollUntilBufferContains(this.page, "CAPTURED"); + }, +); + +Then( + "the captured text should contain {string}", + async function (this: KoluWorld, expected: string) { + const fs = await import("node:fs/promises"); + const text = await fs.readFile("/tmp/kolu-cap.txt", "utf-8"); + assert.ok( + text.includes(expected), + `Captured pane text does not contain "${expected}". Got: ${text.slice(0, 500)}`, + ); + }, +);