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.
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.
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 · 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.0.1-alpha.18"))
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.18</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.
A Server is a client for one tmux server. Server.open
takes a ServerConfig that describes the server to reach, and
Server.newSession(String) creates a Session.
A session holds Windows, and a window splits into Panes:
// Given: Path socket
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");
}That leaves a session behind on a tmux server of its own. Read it back:
// 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(); // → 2Every value after a → is asserted against a real tmux: the
documentation suite runs each Java
block on this page. A block that starts with // Given: Server server reads a
server the suite supplies.
Server.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:
// 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 editServer.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:
// 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]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:
// 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 those 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.
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.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.- 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. They are on Maven Central,
the Scala artifacts suffixed _3.
-
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. Finds its way around, reads what a pane shows, runs a command and waits for its exit status, and has bounded waits for text, a channel or new output in place of pushed notifications. -
libtmux-junit5— test your code against real tmux. One server per test. A JVM killed outright leaves its server for the next run sharing the fixture root to reap. -
libtmux-kotlin— Kotlin ergonomics. Optional: the core is already null-safe from Kotlin without it. -
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. A separate implementation fromlibtmux-workspace, whose package documentation states how their document shapes and building behaviour diverge.
Not published, and part of how the library is built:
examples/ · integration-tests/ ·
module-tests/ · benchmarks/ ·
docs/ · tools/ · build-logic/
A directory is a published artifact exactly when it appears in the list above,
and platformCoversEveryPublishedModule fails the build when the published
artifacts stop matching the BOM.
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:
// 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.0.1-alpha.18"))
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:
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:
// 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-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 every newer one:
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:
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:
// 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, and the jars carry no OSGi bundle headers. -
Linux, including WSL. CI runs on Ubuntu. macOS is supported on a best-effort basis, and CI coverage for it is highly limited.
-
tmux 3.3a through 3.7c. The whole real-tmux suite runs against every one of those releases, and each lane checks it ran the tmux it is named after:
$ ./gradlew testTmuxMatrix -PlibtmuxMatrix=/path/to/tmux/builds -
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. -
Alpha. Any release may change or remove exported identifiers without a deprecation period, including in ways that do not compile. Pin an exact version. Not recommended for production.
-
Tested against real tmux. The real-tmux suite runs against every supported release on every push.
Changes are recorded in CHANGELOG.md; 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 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 |
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.