Alpha. Releases carry an
-alphaprerelease tag. The API is not settled, and any release may change or remove exported identifiers without a deprecation period. Pin an exact version. Not recommended for production.
Typed, blocking access to tmux from the JVM.
A sibling of the Python libtmux, targeting practical parity while reading as Java rather than as a translation.
ServerConfig config = ServerConfig.builder()
.endpoint(ServerEndpoint.socketPath(socket))
.build();
try (Server server = Server.open(config)) {
Session session = server.newSession("demo");
Window window = session.newWindow("build");
Pane pane = window.split();
pane.sendLine("echo hello from libtmux");
}Which leaves this behind, and reading it back is where the library earns its keep:
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(); // → 2Every Java snippet in this file, in every package README, and in every guide is
compiled and then run against a real tmux by docs-tests. A
snippet that stopped working fails the build; one that claims the compiler rejects
it must actually be rejected.
Each block below runs against a real tmux server, and every value after a → is
asserted. If any of them stopped being true, the build would fail.
Session session = server.newSession("demo");
Window editor = session.newWindow("editor");
Pane right = editor.split();
session.name(); // → demo
editor.name(); // → editor
editor.refresh().panes().size(); // → 2One read hands you handles. Walking them issues no further commands, so a traversal cannot see a half-changed server.
server.newSession("demo").newWindow("editor");
List<String> names = server.windows().stream().map(Window::name).sorted().toList();
names.contains("editor"); // → true
server.sessions().size(); // → 2server.sessions().get(0).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 value, so it can also say what it is — which a lambda cannot:
Window_.name().startsWith("edit").describe(); // → window_name starts-with editserver.newSession("build");
Session build = Selections.exactlyOne(
server.sessions().stream().filter(Session_.name().is("build")).toList());
build.name(); // → buildexactlyOne raises NoMatchException for none and MultipleMatchesException
for several, because those are different bugs in the calling code.
Pane pane = server.sessions().get(0).windows().get(0).panes().get(0);
pane.sendLine("echo hello from libtmux");
pane.capture().isEmpty(); // → falsePane pane = server.sessions().get(0).windows().get(0).panes().get(0);
pane.window().session().name(); // → libtmuxNothing is hidden behind the typed API. Every object can reach tmux directly, and a nonzero exit is data rather than an exception:
server.cmd("display-message", "-p", "#{version}").succeeded(); // → true
server.cmd("kill-session", "-t", "=nope").succeeded(); // → falseCode running inside a pane — a script in a split, a tmux hook, an agent — can
ask where it is. tmux writes TMUX and TMUX_PANE into every pane it spawns,
and TmuxEnvironment reads them back:
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 those two variables are already set, so TmuxEnvironment.current()
takes nothing and returns empty when there is no pane to describe. This README is
not running inside one, so the example supplies them.
| to stop | write | which costs |
|---|---|---|
| paying for a process per command | .mode(ExecutionMode.CONTROL) |
one tmux client, then reused |
| waiting on the thread you were handed | .mode(ExecutionMode.VIRTUAL) |
a virtual thread per command |
| round-tripping to learn what you just made | server.chain() |
one request, however many steps |
ServerConfig config = ServerConfig.builder()
.endpoint(ServerEndpoint.socketPath(socket))
.mode(ExecutionMode.CONTROL)
.build();A carrier can also be chosen from outside the program that uses one, so trying another costs nothing:
$ LIBTMUX_MODE=control java -jar app.jarA carrier and a grouping are separate choices that compose, and neither changes what a call returns: the same filter answers identically under each. What that costs is measured in the benchmark, which shows the identical answers next to the different prices.
A capture is a moment, not a live view. server.sessions() reads tmux once
and hands back handles. Walking from a session to its windows to their panes and
back issues no further commands, so a traversal cannot observe a half-changed
server. refresh() is how you look again.
Filters are values. An expression drops into a stream unchanged and can also be printed, stored, or translated:
List<Window> editors = server.windows().stream()
.filter(Window_.name().startsWith("edit"))
.toList();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 rather than collapsing both into one error.
Group io.github.libtmux. Each directory is its own artifact, with its own
README, and each is on Maven Central.
-
libtmux— the library itself. Transport, snapshots, entities, options, hooks, batching, control mode, query model. No runtime dependencies. -
libtmux-bom— name a version once, and every coordinate below follows it. -
libtmux-mcp— give a model a tmux server, over the Model Context Protocol. Finds its way around, reads what a pane shows, runs a command and waits for its exit status, and pushes notifications as tmux changes. -
libtmux-junit5— test your code against real tmux. One server per test, guaranteed gone afterwards even if the JVM is killed. -
libtmux-kotlin— Kotlin ergonomics. Optional: the core is already null-safe from Kotlin without it. -
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.
Not published, and part of how the library is built:
examples/ · integration-tests/ ·
docs-tests/ · benchmarks/ ·
scripts/ · build-logic/
A directory is a published artifact exactly when it appears above, and
platformCoversEveryPublishedModule fails the build if that stops being true.
Name the version once, through the platform, and every other coordinate follows it. That is what stops a project mixing two releases of modules that were built against each other.
dependencies {
implementation(platform("io.github.libtmux:libtmux-bom:0.0.1-alpha.7"))
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.0.1-alpha.7</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>io.github.libtmux</groupId>
<artifactId>libtmux</artifactId>
</dependency>To build against an unreleased change instead, ./gradlew publishToMavenLocal
installs every module into your local repository under the same coordinates.
Both work without a wrapper, because the core is annotated with JSpecify and carries no Scala version suffix.
Kotlin sees the API as null-safe rather than as platform types — Kotlin has
read JSpecify since 1.5.20. Server is AutoCloseable, so use {} works, and
the Consumer<Builder> overloads take trailing lambdas. libtmux-kotlin adds
what Java cannot express: absence as null rather than Optional, and !expr
on a filter.
Scala consumes the Java artifacts directly. There is no _2.13 or _3
build, and there should not be — a Java artifact carrying a Scala suffix is a
bug. See the Scala guide.
JDK 21 or newer.
tmux 3.2a through 3.7b. That range is not a claim: the whole real-tmux suite runs against every one of those releases, and each lane checks it really ran the tmux it is named after.
$ ./gradlew testTmuxMatrix -PlibtmuxMatrix=/path/to/tmux/builds- Getting started
- Execution modes — and the measured comparison
- Filtering
- Options and hooks
- Batching and chaining
- Snapshots and handles
- Streaming
- Driving tmux from a model
- Testing with real tmux
- Kotlin and Scala
- Releasing
Whole runnable programs live in examples/, and the suite there runs
every one of them against a real tmux.
The design is recorded under docs/spikes/. Each note carries the measurements
behind the decision it records, including the ones that overturned an earlier
choice.
.github/CONTRIBUTING.md covers the gate, the tmux
matrix, and why every server this suite starts lives under a path naming this
port.
Alpha. Releases carry an -alpha prerelease tag. The API is not settled,
and any release may change or remove exported identifiers without a deprecation
period. Pin an exact version. Not recommended for production.
What that means in practice:
- Any release may change or remove exported identifiers, without a deprecation period, including in ways that do not compile.
- Only the newest release is supported. There are no backports.
- Pin an exact version. A range will move under you.
- What is not alpha is the tmux correctness: the whole real-tmux suite runs against all eight supported releases on every push.
Changes are recorded in CHANGELOG.md; how a release is cut is
in RELEASING.md.
MIT. See LICENSE.