Typed, blocking access to tmux from the JVM.
A sibling of the Python libtmux, targeting
practical parity. It uses Java types: Optional for absence, AutoCloseable
servers and typed query fields.
Releases are numbered
0.1.N, and each release incrementsN. The API is not settled: any release may change or remove exported identifiers, and the changelog marks each break. Pin an exact version to avoid one.
Create sessions, windows and panes,
find and filter what a Server holds, and
run a command and read its exit status. It suits tools that drive
a terminal program, test suites that need a real tmux, and agents that share a
terminal.
Contents — Quick start · Find things · Create and run · Where am I · Watch · Round trips · Modules · Leaves out · Support · Documentation
Add the library through libtmux-bom, which names one version
for every BOM-managed coordinate. It needs JDK 25 or newer:
dependencies {
implementation(platform("io.github.libtmux:libtmux-bom:0.1.2"))
implementation("io.github.libtmux:libtmux")
testImplementation("io.github.libtmux:libtmux-junit5")
}Maven
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.libtmux</groupId>
<artifactId>libtmux-bom</artifactId>
<version>0.1.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>io.github.libtmux</groupId>
<artifactId>libtmux</artifactId>
</dependency>A Server is a client for one tmux server, and
Server.open takes a ServerConfig that says which.
This program starts a tmux server of its own, makes a Session, a
Window and a split Pane, runs a command, and removes the
server:
import io.github.libtmux.Pane;
import io.github.libtmux.PaneRun;
import io.github.libtmux.Server;
import io.github.libtmux.ServerConfig;
import io.github.libtmux.ServerEndpoint;
import io.github.libtmux.Session;
import io.github.libtmux.Window;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
Path directory = Files.createTempDirectory("demo-");
Path socket = directory.resolve("tmux.sock");
ServerConfig config = ServerConfig.builder()
.endpoint(ServerEndpoint.socketPath(socket))
.build();
try (Server server = Server.open(config)) {
try {
Session session = server.newSession("demo");
Window window = session.newWindow("build");
Pane pane = window.split();
PaneRun ran = pane.run("echo hello from libtmux", Duration.ofSeconds(30));
ran.output(); // → [hello from libtmux]
ran.exitStatus().getAsInt(); // → 0
} finally {
server.killServer();
}
} finally {
Files.deleteIfExists(socket);
Files.delete(directory);
}Server.killServer stops tmux itself. Closing a client
leaves the tmux server running, and tmux leaves its socket file behind.
Session.kill, Window.kill and
Pane.kill close one thing, and
Server.killSession closes a session by name.
Later examples start from a server you already have, as their // Given:
line says:
// Given: Server server
Session session = server.newSession("demo");
Window window = session.newWindow("build");
Pane pane = window.split();
pane.sendLine("echo hello from libtmux");
session.name(); // → demo
window.refresh().panes().size(); // → 2Server.sessions(): what tmux has now
One read hands you handles. Server.windows() and
Server.panes() are its siblings. Walking the handles issues no
further commands, so a traversal cannot see a half-changed server:
import io.github.libtmux.Window;
import java.util.List;
// Given: Server server
Window editor = server.newSession("demo").newWindow("editor");
List<String> names = server.windows().stream().map(Window::name).sorted().toList();
names.contains("editor"); // → true
editor.session().name(); // → demoA handle holds what tmux said when you read it. Window.refresh()
reads again.
Pane.window: walk back up
A handle reaches up as well as down, through Pane.window and
Window.session:
// Given: Server server
Pane pane = server.sessions().get(0).windows().get(0).panes().get(0);
pane.window().session().name(); // → libtmuxWindow_: filter without asking tmux again
Window_ has the typed fields of a Window. An expression
built from one is a Predicate, so it drops into a stream:
// Given: Server server
server.newSession("build").newWindow("editor");
List<Window> editors = server.windows().stream()
.filter(Window_.name().startsWith("edit"))
.toList();
editors.size(); // → 1
editors.get(0).name(); // → editorAn expression is a FilterExpr, which is a value, so
describe() can say what it is — which a lambda cannot:
Window_.name().startsWith("edit").describe(); // → window_name starts-with editTmuxFilters.format: what tmux filters, and what stays here
Server.windows(FilterExpr) sends an expression tmux can
evaluate as list-windows -f, so tmux returns only the matches. Any other
expression reads the whole server and filters here.
TmuxFilters.format shows which, and is empty when the
expression stays local:
import io.github.libtmux.Pane_;
import io.github.libtmux.Window;
import io.github.libtmux.Window_;
import io.github.libtmux.query.FilterExpr;
import io.github.libtmux.query.TmuxFilters;
// Given: Server server
server.newSession("work").newWindow("editor");
FilterExpr<Window> editors = Window_.name().startsWith("edit");
TmuxFilters.format(editors).orElseThrow(); // → #{m:edit*,#{window_name}}
server.windows(editors).size(); // → 1
FilterExpr<Window> nested = Window_.panes().any(Pane_.command().is("nvim"));
TmuxFilters.format(nested).isPresent(); // → falseMore: Filtering.
Server.session(String): one thing, or none
One read answers. Server.session(String) returns an
Optional that is empty when no session has the name. Call
orElseThrow where a missing session is a bug:
// Given: Server server
server.newSession("build");
server.session("build").orElseThrow().name(); // → build
server.session("absent").isPresent(); // → falseThat is how "this session, or a new one" stays a single read:
// Given: Server server
Session work = server.session("work").orElseGet(() -> server.newSession("work"));
work.name(); // → workSelections.exactlyOne: say how many you expect
Selections.exactlyOne takes the matches and insists on one:
// Given: Server server
server.newSession("build");
Session build = Selections.exactlyOne(
server.sessions().stream().filter(Session_.name().is("build")).toList());
build.name(); // → buildIt raises CardinalityException.NoMatch for none and
CardinalityException.MultipleMatches for several, because those are different
bugs in the calling code.
Session.newWindow: make things
Session.newWindow and Window.split each return the new
handle:
import io.github.libtmux.Pane;
import io.github.libtmux.Session;
import io.github.libtmux.Window;
// Given: Server server
Session session = server.newSession("demo");
Window editor = session.newWindow("editor");
Pane right = editor.split();
session.name(); // → demo
editor.name(); // → editor
editor.refresh().panes().size(); // → 2Pane.sendLine: type, then read the screen
Pane.sendLine types a line into a pane, and Pane.capture
reads what it shows:
// Given: Server server
Pane pane = server.sessions().get(0).windows().get(0).panes().get(0);
pane.sendLine("echo hello from libtmux");
pane.capture().isEmpty(); // → falsePane.run: a command to its end
To get a command's exit status, call Pane.run instead of typing
the command and reading the screen. It returns a PaneRun whose
status is the shell's and whose output is the command's alone:
// Given: Pane pane
PaneRun ran = pane.run("printf 'built\\n'; exit 3", Duration.ofSeconds(30));
ran.exitStatus().getAsInt(); // → 3
ran.output(); // → [built]Pane.resize: change a size
Pane.resize grows a pane by a number of cells toward a
Direction. Pane.resizeTo and
Window.resizeTo set a size in Dimensions:
import io.github.libtmux.Dimensions;
import io.github.libtmux.Direction;
import io.github.libtmux.Pane;
// Given: Window window
window.resizeTo(new Dimensions(100, 30));
Pane top = window.panes().get(0);
Pane bottom = window.split();
top.resizeTo(new Dimensions(100, 20));
bottom.resize(Direction.UP, 3);
top.refresh().size(); // → 100x17
bottom.refresh().size(); // → 100x12Session.options(): set and read an option
Options reaches the options of a Session, a
Window or a Pane, and Hooks binds a tmux
command to an event at the same scopes:
import io.github.libtmux.OptionKey;
// Given: Session session
session.options().set(OptionKey.HISTORY_LIMIT, 50_000);
session.hooks().set("after-new-window", "display-message created");
session.options().get(OptionKey.HISTORY_LIMIT).orElseThrow(); // → 50000
session.hooks().all().get("after-new-window"); // → [display-message created]More: Options and hooks.
Server.cmd: any tmux command
Server.cmd sends any tmux command. A nonzero exit shows up in the
CommandResult and throws nothing:
// Given: Server server
server.cmd("display-message", "-p", "#{version}").succeeded(); // → true
server.cmd("kill-session", "-t", "=nope").succeeded(); // → falseTmuxEnvironment: which pane am I in
Code running inside a pane can ask where it is. tmux writes TMUX and
TMUX_PANE into every pane it spawns, and TmuxEnvironment
reads them back:
import io.github.libtmux.TmuxEnvironment;
import java.util.Map;
// Given: Path socket
Map<String, String> inside = Map.of("TMUX", socket + ",1,$0", "TMUX_PANE", "%0");
TmuxEnvironment here = TmuxEnvironment.of(inside).orElseThrow();
here.session().value(); // → $0
here.pane().orElseThrow().value(); // → %0In a real pane, TmuxEnvironment.current() reads them for
you and returns empty outside tmux. The example supplies them instead.
A ServerConfig with no endpoint passes tmux no socket, so tmux
follows TMUX itself, unless LIBTMUX_SOCKET_PATH or LIBTMUX_SOCKET names
another server. TmuxEnvironment.config() asks for the
pane's server by path.
Server.environment: what a later pane will see
tmux keeps an environment per server and per session, and gives it to every
process it starts afterwards. Server.environment reaches the
server's:
// Given: Server server
server.environment().set("LIBTMUX_TOKEN", "abc123");
server.environment().get("LIBTMUX_TOKEN").orElseThrow(); // → abc123Server.control: output as tmux pushes it
Server.control attaches a ControlClient
to a session, and subscribeOutput delivers what tmux pushes,
with a gap where the buffer overflowed. It needs tmux 3.7 or newer:
import io.github.libtmux.control.ControlClient;
import io.github.libtmux.control.Delivery;
import io.github.libtmux.control.EventSubscription;
import io.github.libtmux.control.PaneOutput;
import java.time.Duration;
// Given: Server server, Session session
try (ControlClient client = server.control(session);
EventSubscription<PaneOutput> output = client.subscribeOutput(32)) {
client.send("send-keys", "-t", session.name(), "echo streamed", "Enter");
StringBuilder seen = new StringBuilder();
while (seen.indexOf("streamed") < 0) {
seen.append(Delivery.kept(output.next(Duration.ofSeconds(5)).orElseThrow()).data());
}
seen.indexOf("streamed") >= 0; // → true
}More: Streaming.
Server.chain(): dependent commands in one request
Server.batch() sends independent commands in one invocation.
Server.chain() does the same for dependent ones, letting tmux
carry the current target from one step to the next, with no round trip to learn
an id:
// Given: Server server
server.chain()
.newWindow("built")
.splitLeftRight()
.sendLine("echo chained")
.run();
server.windows().stream().anyMatch(w -> w.name().equals("built")); // → trueBoth retain an outcome for every operation. A failure says how certain it is: "tmux never started" and "tmux timed out halfway" call for opposite recovery, so the transport reports which happened.
The process transport and the control
client log through the JDK's own
System.Logger, so the library brings no logging dependency.
Turn on DEBUG for io.github.libtmux and each command is one line:
tmux list-sessions exited 0 in 4 ms
tmux control display-message complete in 1 ms
Only the verb is written, never its arguments: those carry session names, pane contents and whatever was typed.
Both use the Java API directly, because the core is annotated with JSpecify and carries no Scala version suffix.
- Kotlin reads the JSpecify annotations (since Kotlin 1.5.20), so a value
is
StringorString?and never a platform type.ServerisAutoCloseable, souse {}works, and theConsumer<Builder>overloads take trailing lambdas. libtmux-kotlinadds what Java cannot express: absence asnullwhere Java hasOptional, and!expron a filter.libtmux-clojureis a data-first façade: captured maps and vectors for local Clojure processing, Java refs for explicit tmux effects, and optional bounded async and control adapters.- Scala can consume the Java artifacts directly; the direct Java guide shows how.
libtmux-scalais a Scala 3 facade with its own_3artifacts. The suffix applies only to the facade, never to the Java artifact.
Group io.github.libtmux. Each listed published directory is an artifact with
its own README. Published modules are on Maven
Central, with Scala
artifacts suffixed _3; the Clojure modules below are pre-release.
-
libtmux— the library itself. Transport, snapshots, entities, options, hooks, batching, control mode, query model. No runtime dependencies, and a real Java module,io.github.libtmux. -
libtmux-bom— name one BOM version, and every BOM-managed coordinate below follows it. -
libtmux-mcp— give a model a tmux server over the Model Context Protocol: read panes, run a command to its exit status, wait for text. -
libtmux-junit5— test your code against real tmux, one server per test. -
libtmux-kotlin— Kotlin ergonomics. Optional: the core is already null-safe from Kotlin without it. -
libtmux-clojure— captured persistent maps and vectors, explicit effects, bounded work, and control streams. Pre-release; see its README before choosing a coordinate. -
libtmux-clojure-core-async— optional core.async ownership and backpressure over the Clojure control stream. Pre-release with the façade. -
libtmux-scala— Scala 3 collections, opaque handles over the Java ones, and the typed query DSL. -
libtmux-scala-cats— optional Cats Effect resources and FS2 observations. -
libtmux-scala-ox— an optional OxFlowover subscriptions and live views. -
libtmux-jackson— a filter expression as a versioned JSON document, so it can be stored, sent, or written by something that is not a Java program. -
libtmux-workspace— build a session from a tmuxp-shaped YAML file. -
libtmux-workspace-cli— thetmux-workspacecommand: load, capture, convert and import workspaces, with tmuxp's command names and JSON or NDJSON output.
Not published, and part of how the library is built:
examples/ · integration-tests/ ·
module-tests/ · benchmarks/ ·
docs/ · tools/ · build-logic/
Each heading below links to the module's directory on GitHub, where its README
lives. Only libtmux is required. The other modules are optional and build on
it, and libtmux-bom only names their versions.
libtmux: the library
Transport, snapshots, entities,
options, hooks, batching, control
mode, and the query model, with no runtime
dependencies, as the named module io.github.libtmux.
Reference: libtmux.org and javadoc.io. Its README maps the packages.
Server.batch sends independent commands in one invocation, and
BatchResult reports an outcome for each:
import io.github.libtmux.batch.BatchResult;
// Given: Server server
BatchResult result = server.batch()
.add("new-window", "-d", "-n", "one")
.add("new-window", "-d", "-n", "two")
.run();
result.succeeded(); // → true
result.operations().size(); // → 2
result.operations().get(0).outcome(); // → COMPLETElibtmux-bom: one version for every module
Names one version for every BOM-managed coordinate, so two releases of modules built against each other cannot be mixed. It has no API of its own; see Maven Central.
Import the platform, and leave the version off the module:
dependencies {
implementation(platform("io.github.libtmux:libtmux-bom:0.1.2"))
implementation("io.github.libtmux:libtmux-jackson") // no version
}libtmux-mcp: a tmux server for agents
Gives a model a tmux server over the Model Context Protocol. It finds its way around, reads what a pane shows, and runs a command and waits for its exit status.
Reference: libtmux.org, the tool pages, and javadoc.io.
An MCP client starts the launcher itself and speaks to it over stdin and stdout. Build it from this repository:
$ ./gradlew :libtmux-mcp:installDistWith libtmux-mcp/build/install/libtmux-mcp/bin on PATH, register it with
Claude Code on the tmux socket named work, offering only the tools that read
tmux. That is the socket the tmux-workspace load command below builds on:
$ claude mcp add tmux \
-e LIBTMUX_TOOLSETS=inspect \
-- libtmux-mcp --socket-name worklibtmux-junit5: tests against real tmux
Test your code against real tmux. @ExtendWith(TmuxExtension.class) gives
each test a server of its own and removes it afterwards, and FakeTmux stands
in where a test has no tmux at all.
Reference: TmuxExtension on libtmux.org and
javadoc.io.
FakeTmux answers the way tmux answers, so a snapshot of it is a
real snapshot and every handle works:
import io.github.libtmux.Pane;
import io.github.libtmux.PaneId;
import io.github.libtmux.Server;
import io.github.libtmux.junit5.FakeTmux;
FakeTmux tmux = new FakeTmux();
PaneId editor = tmux.addSession("work");
tmux.show(editor, "$ make", "make: Nothing to be done.");
try (Server server = tmux.server()) {
Pane pane = server.pane(editor).orElseThrow();
pane.capture().get(1); // → make: Nothing to be done.
}libtmux-kotlin: Kotlin ergonomics
Coroutine wrappers, a session, window, and split DSL, and Flow bridges over
the Java API. Optional: the core is already null-safe from Kotlin without it.
Reference: libtmux.org and javadoc.io.
withServer opens a server and closes it however the
block ends, and newSession declares a session, its
windows, and their splits:
import io.github.libtmux.kotlin.withServer
// Given: config: ServerConfig
withServer(config) { server ->
val session = server.newSession {
name = "editors"
window { name = "left" }
window {
name = "right"
split { toRight(); percent(30) }
}
}
session.windows.size // → 2
}libtmux-clojure: captured Clojure data and explicit effects
The pre-release Clojure façade acquires persistent maps and vectors, preserves their Java references only for explicit tmux operations, and leaves captured data free for normal Clojure collection processing. Its README has an isolated real-tmux quick start and links its control, core.async, MCP, and measurement boundaries.
libtmux-clojure-core-async: bounded core.async observation
The optional adapter owns a bounded virtual worker, subscription, events channel, and terminal channel over a borrowed Clojure control connection. It does not close the connection that a caller supplies. Its README names the ownership and loss contract.
libtmux-scala: Scala 3 collections over the handles
Scala collections, opaque handles over the Java ones, and the typed query DSL.
Reference: libtmux.org and javadoc.io.
Server.panes captures once, and the Pane fields filter the
captured Vector without asking tmux again:
import io.github.libtmux.scaladsl.{config => _, *}
import io.github.libtmux.scaladsl.query.*
import scala.util.Using
Using.resource(Server.open(config)) { server =>
val panes = server.panes()
val wide = panes.matching(Pane.width.atLeast(1) && Pane.active.isTrue)
assert(wide == panes.filter(p => p.info.size().width() >= 1 && p.info.active()))
assert(wide.exactlyOne.isRight)
}libtmux-scala-cats: Cats Effect and FS2
Optional Cats Effect resources and FS2 observations for libtmux-scala.
Reference: libtmux.org and javadoc.io.
Server.resource owns the client for the length of use,
and the capture it returns is an ordinary vector:
import _root_.cats.effect.IO
import io.github.libtmux.scaladsl.cats.{config => _, *}
Server.resource[IO](config).use { server =>
server.panes().flatMap { panes =>
val active = panes.filter(_.info.active())
IO(assert(active.size == 1))
}
}libtmux-scala-ox: Ox flows
An optional Ox Flow over subscriptions and live views, for structured
concurrency on virtual threads.
Reference: Flows on libtmux.org and
javadoc.io.
Flows.liveView emits the current view of a server when the
flow starts, then the latest newer view available at each read. Intermediate
views can be coalesced:
import _root_.ox.{supervised, useCloseableInScope}
import io.github.libtmux.scaladsl.{config => _, *}
import io.github.libtmux.scaladsl.live.LiveView
import io.github.libtmux.scaladsl.ox.Flows
supervised {
val server = useCloseableInScope(Server.open(config))
val live = useCloseableInScope(LiveView.attach(server.sessions().head))
val first = Flows.liveView(live).take(1).runToList()
assert(first.head.snapshot.sessions().size() == 1)
}libtmux-jackson: filters as JSON
A filter expression as a versioned JSON document, so it can be stored, sent, or written by something that is not a Java program.
Reference: FilterJson on libtmux.org and
javadoc.io.
FilterJson writes an expression and reads it back, validated
against a model:
import io.github.libtmux.Pane;
import io.github.libtmux.Pane_;
import io.github.libtmux.jackson.FilterJson;
import io.github.libtmux.jackson.LibTmuxModels;
import io.github.libtmux.query.FilterExpr;
String json = FilterJson.writeString(
Pane_.command().startsWith("nvim"), LibTmuxModels.pane());
FilterExpr<Pane> restored = FilterJson.readString(json, LibTmuxModels.pane());
restored.describe(); // → pane_current_command starts-with nvimlibtmux-workspace: sessions from YAML
Builds a session from a tmuxp-shaped YAML file.
Reference: libtmux.org and javadoc.io.
WorkspaceBuilder.parse reads a description into a value
while it is still text, and build is what touches tmux:
import io.github.libtmux.Session;
import io.github.libtmux.workspace.Workspace;
import io.github.libtmux.workspace.WorkspaceBuilder;
// Given: Server server
Workspace workspace = WorkspaceBuilder.parse("""
session_name: built
windows:
- window_name: editor
layout: even-horizontal
panes:
- shell_command: echo one
- shell_command: echo two
""");
Session session = WorkspaceBuilder.build(server, workspace);
session.windows().get(0).panes().size(); // → 2libtmux-workspace-cli: the tmux-workspace command
Loads, captures, converts, and imports workspaces with tmuxp's command names and JSON or NDJSON output.
It is a separate implementation from libtmux-workspace, whose package
documentation states how their document shapes and building behaviour diverge.
Reference: the command manual, with pages for load
and freeze.
Build the launcher from this repository:
$ ./gradlew :libtmux-workspace-cli:installDistWith libtmux-workspace-cli/build/install/tmux-workspace/bin on PATH, and
a workspace file saved as
workspace.yaml, build it on the tmux socket named work without attaching
to it:
$ tmux-workspace load \
-L work \
-d \
./workspace.yaml- Live objects. A
Sessionor aPaneis what tmux said when it was read, and does not change when tmux does. Read again withWindow.refresh()to see a change. - A logging dependency. The library logs through the JDK's
System.Logger, and writes only a command's verb, never its arguments. - Runtime dependencies.
libtmuxhas none. The adapters carry their own, andlibtmux-bomnames their versions. - Backports. Only the newest release is supported.
-
JDK 25 or newer, on the module path or the classpath.
libtmuxis the named moduleio.github.libtmux. -
Linux, including WSL. macOS is supported on a best-effort basis, with limited CI coverage.
-
tmux 3.3a through 3.7c. The real-tmux suite runs on 3.3a and 3.7c on every push, and on every release between weekly. tmux 3.8 and master are non-blocking lanes. Managed control needs 3.7 or newer and JSON layouts need 3.8; the migration notes list the rest. To run the suite against your own tmux builds:
$ ./gradlew testTmuxMatrix -PlibtmuxMatrix=/path/to/tmux/builds -
No release candidates. A candidate such as
X.Y-rcNranks belowX.Y. See Release candidates. -
Any locale. Text the JVM cannot encode reaches tmux over standard input, so names and typed text arrive intact under
LANG=C(details). OnlyPane.currentPath()needs a UTF-8 locale. -
Releases. Releases are numbered
0.1.N, and each release incrementsN. The API is not settled: any release may change or remove exported identifiers, and the changelog marks each break. Pin an exact version to avoid one.
Changes that span modules are recorded in CHANGELOG.md, and
each module keeps a changelog of its own beside its README, linked from it; how
a release is cut is in RELEASING.md.
Every public type and member is in the API reference, built from the current trunk; each released version's Javadoc is on javadoc.io.
| Getting started | The first program, compiled and run |
| Filtering | An expression is a value |
| Options and hooks | Reading and setting both |
| Batching and chaining | Several commands, one invocation |
| Snapshots and handles | What a capture freezes |
| Streaming | Watching pane output as it happens |
| Failures, telemetry, and pane input | Driving tmux from a service |
| Threads and cancellation | What runs at once |
| Driving tmux from a model | The libtmux-mcp server |
| Testing with real tmux | libtmux-junit5 |
tmux-workspace |
The rules load, convert and import follow |
| Kotlin, Clojure, and Scala | The facades |
examples/ |
Whole programs, each run against a real tmux |
docs/decisions/ |
Short ADRs, each citing the tmux behaviour that forced it |
MIGRATION.md |
What to change when upgrading |
docs/README.md |
Every guide, reference and decision, one line each |
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.
Please use the following BibTeX template to cite libtmux-java in scientific discourse:
@misc{libtmux-java,
author = {Tony Narlock},
year = {2026},
note = {https://libtmux.org/en/java/},
title = {libtmux-java: Java wrapper for tmux}
}.github/CONTRIBUTING.md covers the gate, the tmux
matrix, and why every server this suite starts lives under a path naming this
port.
MIT. See LICENSE.