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)}`,
+ );
+ },
+);