I maintain libtmux, the Python library that's been around since 2016, and am porting it to more languages. This is the TypeScript / Node.js port. In many ways, this is more capable than the current Python implementation.
One of the main ways it differs is keeping immutable snapshots and typed querying of the tmux object graph (Server, Session, Window, Pane). It also supports tmux's control mode and batching of operations. Architecturally, libtmux for Python would have to break its API to adopt this snapshot model. For other libtmux ports, they don't have the encumbrance of worrying about API breakages to downstream users.
A word on AI: This project is experimental, and I used LLMs extensively to build it (with zero runtime dependencies and automated tests against live tmux(1) servers). I keep libtmux's AI policy at https://libtmux.org/en/meta/ai-policy/ based on Pallets', tmux's, and Java's.
There are four npm packages:
The core also exports libtmux/selection, libtmux/engine and libtmux/testing. Those come with libtmux; there's nothing extra to install.
libtmux core: typed Node.js library for tmux
npm · GitHub · Homepage · Guides · API Explorer
What's interesting:
The installation commands use Node 22+ with npm and need tmux 3.3a+ installed locally. Save the examples as .mts files; they also run with Bun. The Node commands use tsx.
Install:
npm install libtmux
Save as demo.mts. It creates a separate tmux server, adds two windows, splits the editor window and names a pane using its title. It cleans up that server afterward.
The queries match a session name, window names and a pane title, with OR, a string prefix and relationships.
import { Server } from "libtmux";
const server = new Server({
socketName: `libtmux-demo-${process.pid}`,
configFile: "/dev/null",
});
const session = await server.newSession({ name: "demo" });
try {
const [editor] = await server.batch([
session.plan.newWindow({ name: "editor" }),
session.plan.newWindow({ name: "logs" }),
]);
const build = await editor.split({ shellCommand: "cat" });
await build.setTitle("build");
const snapshot = await server.snapshot();
const matchedSession = snapshot.sessions.where({ name: "demo" }).one();
const matches = snapshot.windows.where({
session: { is: { name: "demo" } },
OR: [
{ name: { startsWith: "edit" } },
{ name: "logs" },
],
panes: { some: { active: true } },
});
const matchedPane = snapshot.panes.where({
title: "build",
session: { is: { name: "demo" } },
window: { is: { name: "editor" } },
}).one();
console.log(`session: ${matchedSession.name}`);
for (const window of matches) {
console.log(`${window.name}: ${window.panes.length} panes`);
}
console.log(`pane: ${matchedPane.title}`);
} finally {
await server.kill();
}
For Node, install tsx:
npm install --save-dev tsx
Run with Node:
node --import tsx demo.mts
Or Bun:
bun demo.mts
Or use the tsx command:
npx tsx demo.mts
Each prints:
session: demo
editor: 2 panes
logs: 1 panes
pane: build
P.S. Try changing the pane's window condition from "editor" to "editor2". .one() raises a NoMatchError carrying the query that failed. Even in the alpha, the library means to raise detailed, typed errors.
The error includes the query:
code: 'NoMatchError',
subcommand: undefined,
query: {
pane_title: 'build',
session: { is: { name: 'demo' } },
window: { is: { name: 'editor2' } }
}
Planning operations, then querying the result
The earlier example uses session.plan to describe work, and server.batch to run it. Each creation normally reads a snapshot back; a batch shares that last read (example).
Save as batch.mts. This counts invocations through recordInvocations, creates the same three windows each way, then chains queries over one snapshot.
import { Server } from "libtmux";
import { recordInvocations } from "libtmux/testing";
const recorder = recordInvocations();
const count = () => recorder.recording().invocations.length;
const server = new Server({
socketName: `libtmux-batch-${process.pid}`,
configFile: "/dev/null",
engine: recorder.engine,
});
try {
const serial = await server.newSession({ name: "serial" });
const session = await server.newSession({ name: "demo" });
const names = ["editor", "logs", "shell"];
let before = count();
for (const name of names) {
await serial.newWindow({ name });
}
console.log(`sequential: ${count() - before} invocations`);
before = count();
const [, logs] = await server.batch(
names.map((name) => session.plan.newWindow({ name })),
);
console.log(`batch: ${count() - before} invocations`);
await logs.panes.one().setTitle("build");
const snapshot = await server.snapshot();
before = count();
const pane = snapshot.panes
.where({ session: { is: { name: "demo" } } })
.where({ window: { is: { name: "logs" } } })
.where({ title: { startsWith: "bui" } })
.one();
if (!pane.session || !pane.window) throw new Error("pane relationships are missing");
console.log(`${pane.session.name} / ${pane.window.name} / ${pane.title}`);
console.log(`queries: ${count() - before} invocations`);
} finally {
await server.kill();
}
node --import tsx batch.mts
sequential: 6 invocations
batch: 4 invocations
demo / logs / build
queries: 0 invocations
Or Bun:
bun batch.mts
Or tsx:
npx tsx batch.mts
The counts cover those window creations, after setup. The queries run in memory; take another snapshot when you want fresh tmux state. A batch runs its commands in order and stops on failure, leaving earlier changes applied.
Control mode: wait for pane output
Server.connect keeps a control observer open. subscribe gives you the events, while commands still use the server's normal executor (agent example).
Save as control.mts. Here we listen before sending the command, then capture what ended up in the pane. The printf arguments keep the completed line out of the echoed command.
import { Server } from "libtmux";
const server = new Server({
socketName: `libtmux-control-${process.pid}`,
configFile: "/dev/null",
});
try {
const session = await server.newSession({
name: "demo",
windowName: "editor",
shellCommand: "sh",
});
const live = await server.connect({ target: session.id });
try {
const pane = (await live.snapshot()).panes.one();
await pane.setTitle("build");
let output = "";
const printed = live.subscribe().find((event) => {
if (event.kind !== "output" || event.paneId !== pane.id) return false;
output += event.data;
return output.includes("build: complete");
}, { timeoutMs: 5_000 });
await pane.sendKeys("printf 'build: %s\\n' complete");
if ((await printed) === undefined) throw new Error("build output never arrived");
console.log("event: build: complete");
const line = (await pane.capture()).find((line) => line === "build: complete");
if (line === undefined) throw new Error("build output missing from capture");
console.log(`capture: ${line}`);
} finally {
await live.close();
}
} finally {
await server.kill();
}
node --import tsx control.mts
event: build: complete
capture: build: complete
Or Bun:
bun control.mts
Or tsx:
npx tsx control.mts
For waiting on a change to the session itself, live.waitFor accepts a snapshot predicate. The waitFor example waits for a list of windows to exist.
Testing your own code
libtmux/testing can also replay recorded calls. Save as replay.mts. This records a snapshot, stops the tmux server, then reads it back from the recording.
import { Server } from "libtmux";
import { recordInvocations, replayInvocations } from "libtmux/testing";
const socketName = `libtmux-record-${process.pid}`;
const server = new Server({ socketName, configFile: "/dev/null" });
const recorder = recordInvocations();
try {
await server.newSession({ name: "demo" });
const recording = new Server({ socketName, engine: recorder.engine });
await recording.snapshot();
} finally {
await server.kill();
}
const replayed = new Server({ engine: replayInvocations(recorder.recording()) });
const snapshot = await replayed.snapshot();
console.log(`replayed: ${snapshot.sessions.one({ name: "demo" }).name}`);
node --import tsx replay.mts
replayed: demo
Or Bun:
bun replay.mts
Or tsx:
npx tsx replay.mts
The recording covers command calls. Control notifications still need a live tmux server (testing guide).
tmux MCP
npm · GitHub · Homepage · Guides · Tools · Resources · API Explorer
An MCP server for interacting with tmux - powered by the above core library (like libtmux-mcp!) It exposes tools for inspecting sessions, reading pane output and running commands.
Start a separate tmux server for the demo:
tmux -L libtmux-ts-mcp-demo -f /dev/null new-session -d -s demo
Use this command with a client that launches stdio MCP servers:
LIBTMUX_SOCKET=libtmux-ts-mcp-demo npx -y @libtmux/mcp
It waits for MCP requests over stdin; it is not an interactive terminal app. The guides cover connecting a client and selecting a tmux socket.
After closing the client, remove the demo server:
tmux -L libtmux-ts-mcp-demo kill-server
Workspace manager
Workspace library
npm · GitHub · Homepage · Guides · API Explorer
Build a tmux session from a configuration object with applyWorkspace. This is like tmuxp's workspace builder, but in TypeScript - and with the newer features the python version doesn't have: batching, control mode, etc.
npm install @libtmux/workspace libtmux
Save as workspace.mts. Like the core example, this uses a separate server and removes it afterward.
import { Server } from "libtmux";
import { applyWorkspace } from "@libtmux/workspace";
const server = new Server({
socketName: `libtmux-workspace-demo-${process.pid}`,
configFile: "/dev/null",
});
try {
const session = await applyWorkspace(server, {
session_name: "demo",
windows: [
{ window_name: "shells", panes: ["printf 'hello\\n'", "pwd"] },
],
});
console.log(session.name, session.windows.at(0)?.panes.length);
} finally {
await server.kill();
}
Run with Node:
node --import tsx workspace.mts
Or Bun:
bun workspace.mts
Or tsx, installed as above:
npx tsx workspace.mts
Each prints:
demo 2
Workspace CLI
npm · GitHub · Homepage · Guides · CLI reference · Builder API
This is like tmuxp, but in TypeScript! Load tmux workspaces from YAML or JSON, or capture a running session as a workspace file. The CLI and workspace library are separate implementations; they don't accept exactly the same configuration fields. Uses the libtmux core library.
Use a current Node 22+ release for the CLI.
npx -y @libtmux/workspace-cli --help
Save as workspace.yaml:
session_name: demo
windows:
- window_name: shells
layout: even-horizontal
panes:
- "printf 'hello\\n'"
- pwd
Load it detached on a separate socket:
npx -y @libtmux/workspace-cli load ./workspace.yaml -L libtmux-workspace-demo -f /dev/null -d --json
Capture that session:
npx -y @libtmux/workspace-cli freeze demo -L libtmux-workspace-demo --json
Remove the demo server when finished:
tmux -L libtmux-workspace-demo kill-server
Intentions and goals
I am porting libtmux to multiple languages. TypeScript is the language I use second most after Python. Beyond that, there's also C++, Elixir, Ruby, Rust, so on. That means I can be docked fairly for spreading my attention across a large estate, meaning I may miss oppurtunities to leverage TS in the most elegant way. In those cases I'd be very grateful to get feedback how it could be done better, as the APIs aren't stable yet!
You don't need a wrapper or library to use tmux. It's a CLI app perfectly doable via command line itself thanks to its targets (UIDs that point to Session, Window, Pane) and formats. There's also control mode.
However, the tmux object graph itself is very elegant and easy to wrap one's brain around - I think it translates perfect to an OOP model. Also, things like control mode can be wrapped in a way that uses a language's unique strengths. TypeScript - being typed - made this so much better than it'd be with plain ECMAScript, having a sense of security like that is nice, Python didn't always have annotations and mypy.