Skip to content

Repository files navigation

libtmux for TypeScript

libtmux for TypeScript

Typed control of tmux for Bun and TypeScript — immutable snapshots, declarative queries, and a library that ships zero runtime dependencies.

Quickstart • Querying • Packages • API reference • Hosted API reference • Examples • Changelog

typescript libtmux libtmux.org license


Warning

Releases are numbered 0.1.N. The API is not settled: any release may change or remove exported identifiers. Pin an exact version, and read the changelog of each package you use before you upgrade. The root changelog summarizes each release and links the package changelogs.

Is this for you?

  • Yes, if you drive tmux from a program (an agent, a workspace launcher, a test harness, a dashboard) and want the terminal's state as typed data, not parsed strings.
  • Probably not, if you want a .tmux.conf generator or a TUI. It controls a running server; it does not write your configuration.
  • The idea: read the whole server once into an immutable snapshot, then query it like data.

Quickstart

$ bun add libtmux
npm, pnpm, yarn
$ npm i libtmux
$ pnpm add libtmux
$ yarn add libtmux
  • tmux 3.3a or newer. An older daemon is refused with VersionTooLowError before any command runs.
  • Bun 1.3.14 or newer, or Node 22 or newer.
  • Runs on Linux, including WSL. macOS is supported on a best-effort basis, and CI coverage for it is highly limited.

Create a session, split a window, and read it back. The program starts a tmux server of its own and stops it on the way out:

import { Server } from "libtmux";

const server = new Server({ socketName: "libtmux-demo" });
try {
  const session = await server.newSession({ name: "work" });
  const editor = await session.newWindow({ name: "editor" });
  await editor.split();

  // One read of the server. Everything after it resolves locally.
  const snapshot = await server.snapshot();
  const found = snapshot.windows.where({ name: "editor" }).one();
  console.log(found.panes.length);
  // 2
} finally {
  await server.kill();
}

More: examples/quickstart runs the same calls and adds a pane action and a failed command.

Find sessions, windows and panes

Server.snapshot: read the server once

new Server() reaches the server your own tmux command does. A snapshot reads it once, then answers every query without another tmux call:

import { Server } from "libtmux";

const server = new Server();
const snapshot = await server.snapshot();

const editors = snapshot.panes.where({ currentCommand: "vim" });
console.log(editors.count(), editors.at(0)?.session?.name);

Selection.where: criteria as data

.where() takes declarative, serializable criteria; .filter() takes an ordinary predicate. They are never overloaded into each other.

// Equality, string operators, AND/OR/NOT, and regular expressions as data.
snapshot.sessions.where({
  AND: [
    { name: { startsWith: "prod" } },
    { windows: { some: { name: { regex: { pattern: "^log", flags: "" } } } } },
  ],
});

// Quantifiers over relations: some / every / none, and is / isNot.
snapshot.windows.where({ session: { is: { name: "work" } } });

// Case-insensitive when you ask for it.
snapshot.sessions.where({ name: { contains: "API", mode: "insensitive" } });

More: every field and operator.

Selection: immutable, ordered, iterable

It is not an Array:

selection.length;
selection.at(-1);
selection.toArray();
[...selection];

selection.one({ name: "work" }); // throws NoMatchError / MultipleMatchesError
selection.oneOrUndefined({ name: "work" });
selection.exists({ name: "work" });

encodeWhereDocument: store a query

Criteria are data. encodeWhereDocument writes a model-tagged query; decodeWhereDocument validates one read from a config file, MCP call, or CLI.

Refresh an object

Window.refreshed: read a handle again

A Session, Window or Pane you hold is what tmux said when it was read. refreshed() reads it again and returns a new handle, and the handle you called it on stays as it was. Session and Pane have it too.

import { Server } from "libtmux";

const server = new Server({ socketName: "libtmux-refresh" });
try {
  await server.newSession({ name: "work", windowName: "editor" });
  const window = (await server.snapshot()).windows.one({ name: "editor" });

  await window.rename("build");
  console.log(window.name, (await window.refreshed()).name);
  // editor build
} finally {
  await server.kill();
}

Type into a pane

Pane.sendKeys and Pane.capture: type, then read

Window.split makes the pane and Pane.resize sets its height. sendKeys returns when tmux has the keys, so read until cat has echoed them:

import { Server } from "libtmux";

const server = new Server({ socketName: "libtmux-pane" });
try {
  const session = await server.newSession({ name: "work" });
  const pane = await session.windows.one().split({ shellCommand: "cat" });
  await pane.resize({ height: 5 });

  await pane.sendKeys("hello");
  let lines = await pane.capture();
  while (lines.length === 0) lines = await pane.capture();
  console.log(lines[0], (await pane.refreshed()).height);
  // hello 5
} finally {
  await server.kill();
}

More: options and hooks, from Session.setOption to Server.setHook.

Run several commands at once

Server.batch: plan, then run

plan describes a mutation without running it. batch runs the planned commands in order and resolves each to a typed handle, from one final snapshot:

import { Server } from "libtmux";

const server = new Server({ socketName: "libtmux-batch" });
try {
  const session = await server.newSession({ name: "work" });
  const [editor, logs] = await server.batch([
    session.plan.newWindow({ name: "editor" }),
    session.plan.newWindow({ name: "logs" }),
  ]);
  const [pane] = await server.batch([editor.plan.split()]);

  await pane.sendKeys("echo hello");
  console.log(editor.name, logs.name);
  // editor logs
} finally {
  await server.kill();
}

pipeline runs raw tmux arguments the same way, one result each. More: batching.

Watch tmux change

Server.watch: control mode, no polling

watch() holds one persistent tmux -C connection and yields each change as tmux announces it. Open it, await ready(), then act:

import { Server } from "libtmux";

const server = new Server({ socketName: "libtmux-watch" });
try {
  const session = await server.newSession({ name: "work" });
  await using events = server.watch({ target: session.id });
  await events.ready();

  await session.newWindow({ name: "build" });
  const opened = await events.find((event) => event.kind === "window-add", {
    timeoutMs: 10_000,
  });
  console.log(opened?.kind);
  // window-add
} finally {
  await server.kill();
}
  • Needs tmux 3.7 or newer.
  • Server.connect pairs the same observer with a server's commands, and waitFor waits for a state to hold.

More: watching.

Packages

The four packages share one release version.

Package What it is npm
libtmux The library. Server, session, window, pane and client handles over a snapshot. npm
@libtmux/mcp An MCP server exposing tmux to an AI agent. npm
@libtmux/workspace Declarative workspace builder, tmuxp-shaped config. npm
@libtmux/workspace-cli The native tmux-workspace command for Node and Bun. npm
examples Runnable examples, executed as tests. —

libtmux: the library

Server, Session, Window, Pane and Client handles over an immutable snapshot of tmux.

API reference · npm · Changelog

$ bun add libtmux
import { Server } from "libtmux";

const server = new Server({ socketName: "libtmux-package" });
try {
  const session = await server.newSession({ name: "work" });
  const editor = await session.newWindow({ name: "editor" });
  await editor.split();

  await editor.panes.at(0)?.sendKeys("echo hello");
  const lines = await editor.panes.at(0)?.capture();
} finally {
  await server.kill();
}

Read next: Snapshots · Querying · Operations · Watching · Recipes · Errors · API reference

@libtmux/mcp: tmux for an AI agent

A stdio MCP server. Point it at a socket and an agent can list sessions, read a pane, send keys, and wait for output instead of polling for it.

API reference · Tools · npm · Changelog

With no socket chosen it passes tmux none, so tmux picks the server: the one it is launched inside, otherwise its default socket:

$ npx -y @libtmux/mcp@0.1.2

Add it to any MCP client — this is the whole configuration:

{
  "mcpServers": {
    "tmux": {
      "command": "npx",
      "args": ["-y", "@libtmux/mcp@0.1.2"],
      "env": { "LIBTMUX_SOCKET": "agent" }
    }
  }
}
Claude Code, in one command
$ claude mcp add tmux --env LIBTMUX_SOCKET=agent -- \
    npx -y @libtmux/mcp@0.1.2

The tools an agent reaches for first:

Tool What it does
list_sessions Lists stable session identities and metadata
capture_since Returns only pane output after a cursor
wait_for_text Waits for bounded literal or regular-expression matches
run_shell_command Runs a command through a pane and reports output and exit status
create_session Creates a session without accepting a command or environment value

Tools are grouped in the toolsets inspect, manage, execute and teardown. tmux://capabilities is the one resource.

Read next: Why it exists · Configuration · Toolsets and trust · Choosing the right tool

@libtmux/workspace: declarative sessions

Describe a session; apply it. Applying twice converges rather than duplicating.

API reference · npm · Changelog

$ bun add @libtmux/workspace libtmux
import { Server } from "libtmux";
import { applyWorkspace } from "@libtmux/workspace";

const server = new Server({ socketName: "libtmux-workspace" });
try {
  await applyWorkspace(server, {
    session_name: "api",
    windows: [
      { window_name: "editor", panes: ["vim", "git status"] },
      { window_name: "server", panes: [{ shell_command: "bun dev", focus: true }] },
    ],
  });
} finally {
  await server.kill();
}

Read next: The config shape · Converging

@libtmux/workspace-cli: tmuxp workspaces from the shell

tmux-workspace loads, captures and converts tmuxp workspace files on Node or Bun. Ordinary loads start no Python.

Command reference · npm · Changelog

Save this document as workspace.yaml:

session_name: dev
windows:
  - window_name: editor
    layout: main-vertical
    panes:
      - vim
      - top

Load it detached, on a socket of its own:

$ npx -y @libtmux/workspace-cli@0.1.2 load ./workspace.yaml \
    -L workspace-cli-demo \
    -d

Read next: Install · Load and capture · Command reference

examples: runnable programs

Reading a server, watching it, driving a pane and waiting, and building a workspace.

$ bun test examples

How work is arranged

Observation, planning and concurrency compose around one command engine. The full table has more.

Mode Turn it on When to use it
connected await server.connect() Pairing commands with a persistent event observer
watching server.watch() Reacting to a change rather than polling to find it
pipeline server.pipeline([…]) Ordered commands when printed output is enough
planned .plan + server.batch([…]) Ordered mutations that must return typed handles
concurrent Promise.all Independent work that may safely overlap
  • Twelve windows cost 25 tmux invocations one at a time, 13 through pipeline and 14 through batch. Each count includes the query that reads the result back.
  • A snapshot is one invocation whatever the server's size, and querying it is none.
  • The benchmarks add wall-clock time and say why Promise.all buys nothing over doing the same work in order.
$ bun packages/libtmux/scripts/bench-modes.ts

maxInFlight: bound the tmux processes

Every invocation is a tmux client process, so a Promise.all over a whole server starts that many. maxInFlight caps them. A custom TmuxEngine runs tmux over SSH or in a container instead.

import { Server } from "libtmux";

const server = new Server({ socketName: "libtmux-bounded", maxInFlight: 4 });
try {
  const session = await server.newSession({ name: "work" });
  const names = ["a", "b", "c", "d", "e", "f"];
  await Promise.all(names.map(async (name) => session.newWindow({ name })));
  console.log((await server.snapshot()).windows.count());
  // 7
} finally {
  await server.kill();
}

Support and status

  • No runtime dependencies in the library. A test holds it. @libtmux/mcp and @libtmux/workspace declare their own.
  • Real tmux. CI runs the suite against real servers, with no mocks standing in for tmux.
  • Tracks Python libtmux. Names and shapes follow it where TypeScript agrees. Each departure is a recorded decision.

Repository

packages/libtmux         the library
packages/mcp             the MCP server
packages/workspace       the workspace builder
packages/workspace-cli   the tmux-workspace command
examples                 runnable examples
attic                    reference notes

Working here: AGENTS.md routes to the policy for a change. Writing rules are in .github/WRITING.md; gates, real tmux and releases are in .github/CONTRIBUTING.md.

AI policy

Use these packages with AI tools as you like. When contributing, check what you submit and wait for a maintainer's invitation before preparing a pull request. See the AI policy, also kept as AI_POLICY.md.

Attribution

Please use the following BibTeX template to cite libtmux-ts in scientific discourse:

@misc{libtmux-ts,
   author = {Tony Narlock},
   year = {2026},
   note = {https://libtmux.org/en/ts/},
   title = {libtmux-ts: TypeScript wrapper for tmux}
}

License

MIT — a port of tmux-python/libtmux.

Used by

Contributors

Languages