API changes that require updates to calling code are recorded here. See CHANGELOG.md, and the changelog beside each module, for the full change history.
A breaking type is named on its own api-break: line. Mentioning the type in
the prose is not that line.
The unreleased Clojure façade keeps raw task, runtime, adapter, and events-port
constructor vars private. Use libtmux.async/runtime!, submit!, and
libtmux.core-async/observe!; these establish bounded workers and cleanup.
Returned resource classes remain usable in type hints and with-open.
ControlClient.subscribeOutput(int) now bounds queued unescaped payload at
16 MiB as well as the requested event count. Use
ControlClient.subscribeOutput(int, long) to choose another byte limit.
Oldest events are discarded until both limits fit; a chunk larger than the
byte limit discards the buffered prefix and itself. Read Delivery.Gap
before processing the retained suffix. droppedBytes() counts payload loss,
while retainedBytes() excludes JVM object overhead and bytes already read.
For text that follows one subscriber's observed bytes, pass each PaneOutput
to a PaneOutputDecoder, call reset() after every gap, and call finish()
at termination. This prevents bytes on opposite sides of a subscriber gap
from forming a character. Decoder capacity eviction increments resetCount().
The existing PaneOutput.data() remains the client's predecoded view.
EventSubscription.termination() records the first end classification and
original cause. A control exit describes that client, not daemon death;
bare EOF remains unknown. Later close does not replace that record.
Explicitly closing a subscription discards its queued payload even after a
remote end; it preserves the recorded cause and loss counts.
The core, tmux-workspace and libtmux-mcp now take the same steps to find a
server: an explicit option or flag, then LIBTMUX_SOCKET_PATH (passed as -S),
then LIBTMUX_SOCKET (-L), and else no socket at all, which leaves the choice
to tmux. The path wins when both variables are set, and a blank variable counts
as unset. TMUX is never turned into a socket; tmux follows it by itself.
Core. ServerConfig.builder().build() and Server.builder().build() read
the two variables from the process environment once, when they build. A program
that must not follow its operator's environment states a server:
ServerEndpoint.socketPath(path), ServerEndpoint.namedSocket(name), or
ServerEndpoint.defaultSocket() for tmux's own pick. A LIBTMUX_SOCKET that
holds a / now makes build() throw IllegalArgumentException; use
LIBTMUX_SOCKET_PATH for a path. ServerEndpoint.fromEnvironment(Map) applies
the same rules to any map, and TmuxEnvironment.config() is still the way to
select the server in TMUX by its path.
tmux-workspace. -S and -L come first, then the variables, and with none
of them no socket is passed. It used to pass the TMUX socket as -S. Inside a
tmux pane a plain tmux-workspace load -d still reaches the pane's server,
because tmux follows TMUX; a variable now wins over it. An attached load
and --append still refuse a server other than the one in TMUX, so with a
variable set, pass -S or -L of the pane's server or unset the variable to
use them.
libtmux-mcp. It no longer starts a server on the socket libtmux-mcp with
its minimal configuration, and it enables inspect, manage and execute
whichever server it reaches. To serve the old socket, pass
--socket-name libtmux-mcp and LIBTMUX_TOOLSETS=inspect,manage,execute,teardown.
The launcher no longer starts that server, so start it yourself with the
configuration you want; the one it wrote set exit-empty off and status off.
It accepts LIBTMUX_SOCKET and LIBTMUX_SOCKET_PATH together, with the path
winning, and treats either one as unset when it is empty.
Upgrading from alpha.18 keeps the 3.3a core floor: ordinary subprocess
operations, polling, MCP tools and native workspace building run as before.
What changes is that optional tmux features that are unsafe on the connected
daemon's release are now refused before anything is sent. Review the table
below against the daemon you run, then the three changes that can need action:
managed control, Python-backed workspace commands, and Pane.awaitText.
Managed control acquisition requires tmux 3.7 or newer and pins the socket
before -C. The identity is checked through that pin before attaching, so
replacing the original pathname cannot redirect initialization to a new
daemon. In alpha.18 the floor was checked through the process carrier before
launching -C and did not pin the endpoint between that check and attachment.
Against an older daemon, managed control, watches, mirrors and live views are refused before control initialization. Upgrade the daemon to a stable 3.7 or newer, or stay on the polling APIs, which keep the 3.3a floor.
Custom ControlCarrier implementations must implement pinSocket in their
own execution realm. Its alias must retain the socket inode in a private
directory for the attachment's lifetime; replacing the original pathname must
not redirect it. The default method refuses control acquisition, so a carrier
that does not override it can no longer start control. Ordinary commands
through an existing custom transport are unaffected. The built-in local
carrier uses a short private hard link on the socket's filesystem and refuses
before -C if no valid alias can be created. Closing an attachment or
receiving EOF removes only its owned alias and directory; the carrier remains
borrowed. One timeout budget covers qualification and attachment.
The Python bridge still requires Python libtmux whose declared
TMUX_MIN_VERSION is 3.3a or newer and whose own dispatcher enforces that
floor on each command. It now also requires the integer
libtmux.common.TMUX_OPTIONAL_FEATURE_POLICY_VERSION >= 2. It checks both
requirements in the runtime probe and again in the actual child, before
builders, plugins or shell code run. Policy version 1 is refused, and
upgrading tmuxp alone does not supply a command-level version check or the
optional guards.
No libtmux release on PyPI declares that marker yet; it arrives with
tmux-python/libtmux#780.
Until it ships, tmux-workspace shell and extension loading refuse on every
installable Python libtmux. Either install libtmux from that source into the
interpreter named by TMUX_WORKSPACE_PYTHON, or leave shell, plugins and
custom builders alone: native loading and every read command need no Python.
TextOutcome gains PANE_GONE. A pane killed mid-wait is
TextOutcome.PANE_GONE instead of a CommandRejectedException from the
failed read, so remove a catch that treated that exception as the signal and
handle the new outcome, including in any exhaustive switch over
TextOutcome. Pane.watchText takes the same arguments as Pane.awaitText
and also returns the output so far as a TextWait. A condition passed to
Pane.await still raises for a pane that is gone.
The selected daemon determines eligibility, even when the configured client is newer. A refused literal batch runs none of its commands, including a preceding command that would otherwise succeed.
| Operation | Requirement or repair |
|---|---|
| Managed control, watches, mirrors and live views | Stable tmux 3.7+ and a carrier-owned pinned socket. Older daemons are refused before control initialization. |
| Exact named buffer deletion and rename | tmux 3.4+. On 3.3a, a missing name can select an unrelated buffer. |
Pane.paste(text) |
tmux 3.4+ before creating a temporary buffer, because failure cleanup needs exact named deletion. |
Direct paste-buffer -d -b name |
Retained on 3.3a; native lookup already rejects a missing name. |
| Applying a JSON layout | tmux 3.8+. Older daemons are refused with UnsupportedFeatureException before dispatch. |
Literal select-layout on 3.3a |
Invalid operands are refused with UnsupportedFeatureException before every batch effect. Presets, unique prefixes and serialized layouts remain available; -o requires a serialized operand. |
| Clipboard query | Literal refresh-client -l, under any name tmux resolves to it, is refused below tmux 3.6a: on 3.3a it ends the server when a client attached from an xterm or tmux-256color terminal receives the query, and on 3.4 to 3.6 it ends the server when the client leaves before its terminal answers. |
| Percentage splitting | Typed SplitSpec.percent retains -l N%. Literal split-window -p is refused on exact 3.4. |
| Session and window names on exact 3.7 | A name containing . or : is refused before dispatch (IllegalArgumentException), including workspace window names; 3.7a and later keep such names. |
break-pane on exact 3.7 |
Literal calls and unnamed typed calls are refused. Use Pane.breakOut(name) or upgrade to 3.7a+. |
Single-pane breakOut before 3.6 |
The typed call reads the pane's actual post-command context; native success does not print -P output there. |
Captured run-shell output |
Existing 3.5 floor remains; 3.3a and 3.4 lose the requested output. Effect-only shell commands remain available. |
Named breakOut on exact 3.7 applies the name after the pane moves, because
that release ignores -n in its multi-pane branch. A later rename failure can
leave the pane in its new window. An unnamed single-pane break on other
supported releases preserves the existing window name.
Literal command words retain their existing encoding, including semicolons
used as data. Feature guards inspect the literal commands a batch represents.
They do not check a tmux command string nested inside one: an if-shell body,
a run-shell -C command, a hook, a brace block or a source-file file. They
also do not interpret shell text or configured aliases. Do not nest a command
this library refuses. The guard does not look inside it, so tmux 3.3a or 3.7
can still end the server or act on the wrong buffer. Those nested strings keep
native tmux semantics and are outside the managed optional-feature guarantee.
These checks also require native built-in command semantics. A tmux
configuration that replaces commands such as if-shell or run-shell
through command-alias is outside this qualification.
Control-positive matrix cases run from 3.7. Lower lanes keep ordinary operations and explicit before-effect refusal assertions. Documentation control examples compile on every lane and assert the refusal below that feature's floor.
Upgrade the tmux daemon to 3.3a or newer. Installing a newer client alone does not upgrade a daemon that is already running. Finish or save its sessions, stop that daemon, then start the new tmux binary on the same endpoint.
Commands through Java, Kotlin and Scala, including MCP tools and native
workspace loading, refuse an older daemon with UnsupportedFeatureException
before sending the requested operation. Raw commands and batches follow the
same rule. The message says to upgrade tmux and restart any running server, or
to use the previous libtmux release. A daemon older than 3.2 cannot answer the
version probe and is refused the same way.
UnsupportedFeatureException.belowFloor() is true for this refusal and false
when one optional feature is missing from a supported tmux. A handler that
skips optional features should test it first, so that a tmux below the floor
is surfaced and not skipped.
Standalone cmd("-V") and cmd("-h") remain available for executable
diagnostics; their results do not authorize a daemon operation. A version
token that names no release, such as master, fails with
MalformedResponseException. A candidate for 3.3a does not satisfy the
released 3.3a minimum.
Ordinary command requests carry an atomic version guard. Control-client
startup checks the floor through its process carrier before launching -C;
it does not pin the endpoint between that check and attachment. These checks
require native built-in command semantics. A tmux configuration that replaces
commands such as if-shell or run-shell through command-alias is outside
this qualification.
The first session on an unused endpoint checks the configured binary before starting a daemon. Reads leave an unused endpoint empty; cleanup of an absent daemon remains harmless. Optional features retain their own version requirements.
The MCP server names the floor in its instructions and in
tmux://capabilities as minimumTmux. Against an older tmux it prints one
libtmux-mcp: line and exits non-zero at startup, and a tool call answered by
an older daemon mid-session returns the UNSUPPORTED_FEATURE error code. The
workspace CLI reports the refusal with the machine code tmux_unsupported.
Python extensions and the Python shell use Server.requireCompatibleTmux()
before starting Python. This validates the selected daemon or executable
without starting a daemon; later Java operations still run their own version
check. The bridge also requires Python libtmux whose declared
TMUX_MIN_VERSION is 3.3a or newer and whose own dispatcher enforces that
floor on each command. An older installed backend is refused before builders,
plugins or shell code run, and upgrading tmuxp alone does not supply a
command-level version check.
TmuxVersion.compareTo now orders next-X.Y < X.Y-rc < X.Y-rcN < X.Y < X.Ya,
so it agrees with equals; a candidate no longer compares equal to its
release. atLeast follows that order: 3.8-rc and next-3.8 no longer meet a
gate on 3.8, which the 0.0.1-alpha.14 notes below describe the other way
round. Gate a behaviour on the release that ships it, such as
new TmuxVersion(3, 8, "") for JSON layouts and an empty dead-pane pid. Use
sameRelease for a gate on one release's own defect.
TmuxVersion.parse accepts only what tmux has reported (3.3a, 3.8-rc2,
next-3.9), with numbers of up to four digits, and throws
IllegalArgumentException for anything else, such as 3.3a garbage,
next-3.9a or 3.8-rc0.
TmuxVersion.compareTo now orders next-X.Y < X.Y-rc < X.Y-rcN < X.Y < X.Ya,
so it agrees with equals; a candidate no longer compares equal to its release.
Use atLeast for a feature gate, which counts a candidate as its release, and
sameRelease for a gate on one release's own defect. TmuxVersion.parse accepts
only what tmux has reported (3.3a, 3.8-rc2, next-3.9) and throws
IllegalArgumentException for anything else, such as 3.3a garbage.
Each line of stdout() is split at LF alone, so a \r tmux sent stays at the
end of its line, and Buffers.show, pane captures and option reads return it.
Where your code compared a line with a bare string, strip the carriage return
first:
// Given: Server server
CommandResult result = server.cmd("list-sessions", "-F", "#{session_name}");
List<String> lines = result.stdout().stream()
.map(line -> line.endsWith("\r") ? line.substring(0, line.length() - 1) : line)
.toList();Pane.options(), Server.hooks()/Session.hooks()/Window.hooks(),
Server.options()/Server.globalOptions()/Session.options()/
Window.options(), Server.shell(), Server.commands(), Server.buffers(),
Server.environment()/Session.environment(), Server.messageLog(),
Server.prompt(), Server.keys(), Server.batch()/Pane.batch(), and
Server.chain() answer new io.github.libtmux.kotlin wrapper classes
(Options, Hooks, Shell, Commands, Buffers, Environment,
MessageLog, Prompt, Keys, Batch, CommandChain), not the Java types of
the same simple name. Every operation on them that reaches tmux is suspend,
the same contract every other Kotlin wrapper already carries. Code that needs
the Java handle reads it as asJava:
// Given: config: ServerConfig
withServer(config) { server ->
server.hooks().set("after-new-window", "display-message hello")
server.hooks().asJava
}Keys.in(table) answers the Kotlin Keys, not the Java one. Options.get/
set keep both overloads — by name and by OptionKey<T> — as suspend
members on the Kotlin Options.
On the Cats facade, Server.hooks, Pane.options, Server.shell,
Server.commands, Server.buffers, Session.environment,
Server.messageLog, Server.prompt, and Server.keys answer new
io.github.libtmux.scaladsl.cats classes (Hooks[F], Options[F],
Shell[F], Commands[F], Buffers[F], Environment[F], MessageLog[F],
Prompt[F], Keys[F]), not the raw Java handle the accessor used to answer.
Their own reads and mutations run through F, admission-bounded and
cancellable like every other Cats operation; read the Java handle as
asJava. The direct-style libtmux-scala facade is unchanged: these
accessors still answer the raw Java handle there, since direct style is
blocking by design. Keys[F].in(table) answers the Cats Keys[F].
Options[F].get/set keep both overloads — by name and by OptionKey<T> —
each returning F[_].
Building or running on JDK 21 through 24 no longer works. The toolchain and
options.release in every module, the Kotlin and Scala compilation targets,
and the CI matrix move from JDK 21 to JDK 25. Kotlin's oldest supported
consumer compiler stays 2.1; it cannot itself emit JDK 25 bytecode, so the
module's own oldest-consumer check now compiles at that compiler's ceiling,
-jvm-target 23, while still linking against this module's JDK 25 bytecode.
Scala 3.9 accepts -release:25.
api-break: Client
Client.refresh() returns Client, and throws TargetGoneException once the
client has detached, as every other handle's refresh() does; it returned
Optional<Client>. fetchAttachment() throws the same way. Catch
TargetGoneException where you tested for empty.
api-break: Server
Write the option instead:
// Given: Server server
server.globalOptions().set("mouse", "on");api-break: Server api-break: Session api-break: Window api-break: Pane api-break: Client api-break: ControlClient
io.github.libtmux.kotlin.Server/Session/Window/Pane/Client/
ControlClient are new Kotlin classes, distinct from the Java types of the
same simple name. Code that already holds a Java Server wraps it with
Server.fromJava(server), and every wrapper answers its Java handle as
asJava. Every operation that reaches tmux is suspend; captured
state is a property. Optional is already unwrapped to a nullable return, so
the old activeWindowOrNull/activePaneOrNull/floatingOrNull/modeOrNull/
sessionOrNull/paneOrNull/windowOrNull/getOrNull extension functions are
gone — call the property or suspend method directly and read the nullable
result. EventSubscription<T>.deliveries()/awaitDelivery() are gone; use
ControlClient.output(capacity)/events(capacity), a cold Flow over the
same subscription. Pane.awaitText/await/run and Server.control move
from extensions on the Java types to members on the Kotlin wrapper classes,
with the same names and kotlin.time.Duration parameters:
// Given: config: ServerConfig
withServer(config) { server ->
val session = server.newSession("build")
val pane = session.activeWindow?.activePane ?: error("no active pane")
pane.sendLine("echo ready")
}api-break: EventSubscription
A read that overlaps another — two threads in next() at once — or any read
after stream() or publisher() took the subscription throws
IllegalStateException. Readers used to split the events between them, and
each other's gaps with them. Subscribe again for a second reader: each
subscription gets every event. Reading from one thread after another stays
legal.
api-break: LibTmuxException api-break: ObjectDoesNotExistException api-break: ServerNotRunningException api-break: UnsupportedTmuxVersionException api-break: UnencodableTextException api-break: ControlEndedException api-break: TmuxFormatException api-break: TmuxTransportException api-break: TmuxTimeoutException api-break: Selections api-break: SchemaException
Every failure now lives in io.github.libtmux.exception, under an abstract,
sealed LibTmuxException, so a switch in Java, a when in Kotlin and a
match in Scala over it is checked for exhaustiveness. Change the imports and
these names:
| Before | Now |
|---|---|
ObjectDoesNotExistException |
TargetGoneException |
ServerNotRunningException |
ServerUnavailableException |
TmuxTransportException |
DispatchException, thrown as DispatchException.Failed |
TmuxTimeoutException |
DispatchException.TimedOut |
TmuxFormatException |
MalformedResponseException |
UnsupportedTmuxVersionException |
UnsupportedFeatureException |
Selections.NoMatchException |
CardinalityException.NoMatch |
Selections.MultipleMatchesException |
CardinalityException.MultipleMatches |
ControlEndedException, UnencodableTextException |
same names, new package |
A command tmux ran and refused, which threw a bare LibTmuxException, throws
CommandRejectedException. LibTmuxException is abstract; construct a leaf.
A handle used after its Server was closed throws ServerClosedException, an
IllegalStateException outside the tree, where it threw a plain
IllegalStateException or, when the close raced a running command, a
TmuxTransportException. Its outcome() says whether tmux may have run that
command.
DispatchException.safeToRetry() answers whether the same request may be sent
again: always when it never reached tmux, and otherwise only when every command
in it reads. Hand it to your retry library instead of deciding from
outcome().
CardinalityException.MultipleMatches.atLeast() is how many matched, at
least. Selections stops at the second match, so there it is two.
libtmux-jackson's SchemaException is an IllegalArgumentException: a
document that is not a filter is bad input, not a tmux failure.
An interrupt during EventSubscription.stream() or publisher(), which cannot
throw InterruptedException, ends the read with
java.util.concurrent.CancellationException rather than LibTmuxException.
final class Recovery {
static String nextStep(LibTmuxException failure) {
return switch (failure) {
case TargetGoneException gone -> "look the handle up again";
case ServerUnavailableException down -> "start a server";
case CommandRejectedException refused -> "change the request: " + refused.errorLines();
case DispatchException failed -> failed.safeToRetry() ? "send it again" : "read tmux's state first";
case ControlEndedException ended -> "attach again and take a snapshot";
case UnsupportedFeatureException unsupported -> "do without it: " + unsupported.getMessage();
case UnencodableTextException unencodable -> "start the JVM in a UTF-8 locale";
case MalformedResponseException malformed -> "report it: " + malformed.getMessage();
case CardinalityException.NoMatch none -> "nothing matched";
case CardinalityException.MultipleMatches many -> many.atLeast() + " matched";
};
}
}api-break: Notification
Notification.Pause, Continue, Message, and ConfigError join the sealed
set. A switch over Notification with no default no longer compiles until
it handles them; one with a default, or that matches Unknown for
everything else, is unaffected, though those four no longer arrive as
Unknown.
A list, set, or map the core returns is a Kotlin List, Set, or Map. Code
that declared one as MutableList or called add on it no longer compiles;
the call threw before. Copy with toMutableList() to change one. No Java
signature changed, so no type is named here.
api-break: ControlClient
ControlClient.attach(config, session) and its timeout overload are
attachUnfenced. They attach to whatever server now answers the endpoint, and
a replacement server can reuse the session id. Prefer server.control(session),
which leaves unless the live server is the process the capture named. The Cats
Control.attach(config, session, ...) is Control.attachUnfenced for the same
reason.
api-break: EventSubscription
next and next(Duration) return Optional<Delivery<T>>. A full buffer still
drops its oldest event. The next read is Delivery.Gap, carrying how many were
discarded since the previous read, and the reads after that are the events that
remain. droppedCount() is still the total.
cause() is empty when the caller closed the subscription. It is set when the
control client ended it. ControlClient.standardError() is the bounded text
that process wrote to its error stream. A subscription does not reconnect.
Attach again with Server.control and read a snapshot. Events already missed
are not replayed.
api-break: Server
promptHistory and clearPromptHistory are prompt().history() and
prompt().clear(). messages() is messageLog().lines(). runShell,
runShellCapturing and ifShell are shell().run, shell().capturing and
shell().choose. listCommands() is commands().list().
The MCP tool's output field naming a budget cut is now truncated, matching
capture_pane, capture_since, wait_for_text, and run_shell_command.
Read truncated instead of limited from a search_panes result.
TmuxVersion now carries the pre-release a version named, so a server running
3.8-rc reads back and prints as 3.8-rc rather than 3.8. Two consequences
for calling code.
Comparison treats a candidate as the release it names: atLeast against 3.8
is met by 3.8-rc, because tmux freezes features at the candidate and a 3.8
candidate behaves as 3.8 does. A candidate still sorts above the next-3.8
development build tracking toward it, still below 3.8a, and is still not
equals to 3.8 — so code branching on equals sees a value it did not see
before, while code branching on atLeast gains the candidate.
The record gained a fifth component, preRelease. The four-argument
constructor still exists and builds a version with none, so existing calls
compile and behave as before; only equals, hashCode and toString widen to
take the new component into account.
A command carrying text this JVM cannot encode as an argument — any non-ASCII
text under LANG=C, the default in most container images — is sent over tmux's
standard input instead, with source-file -, and arrives intact. No change is
needed. UnencodableTextException, a LibTmuxException subtype, remains for
the one case with no second route: a command that already reads standard input,
or an endpoint — the binary or socket path — this JVM cannot encode, since
nothing but an argument can carry those. It names the character and the fix,
LC_ALL=C.UTF-8, and never the text itself.
Reading needs no change: every command now passes -u, so tmux no longer
replaces non-ASCII in a reply with _ for a client whose locale it cannot
read.
The public overload taking a capture time and a pid but no TmuxVersion is
gone, and the overload taking no identity at all is package-private. A handle
built from either failed at its first real operation, because every handle
command is fenced on the server's pid and version, so neither belonged in the
public API. Build one with the overload that takes both.
// Given: Server server
io.github.libtmux.snapshot.ServerSnapshot.of(
java.time.Instant.now(),
server.snapshot().serverPid().orElseThrow(),
server.version(),
java.util.List.of(), java.util.List.of(), java.util.List.of(), java.util.List.of());bindKey, unbindKey and listKeys moved to Keys, like buffers, options,
hooks and the environment, and gained key tables: in("root") names one.
Binding without one uses prefix, as before; listing without one lists every
table, as before.
| Was | Is |
|---|---|
server.bindKey(key, command) |
server.keys().bind(key, command) |
server.unbindKey(key) |
server.keys().unbind(key) |
server.listKeys() |
server.keys().list() |
// Given: Server server
server.keys().bind("F12", java.util.List.of("display-message", "hello"));
server.keys().unbind("F12");It was a String holding tmux's classic checksummed form before 3.8 and JSON
from 3.8, and nothing but a comment said which. WindowLayout is sealed over
Classic and Json, and value() is the text exactly as tmux reported it.
applyLayout takes either the WindowLayout or, as before, a String.
Notification.LayoutChanged carries one too.
// Given: Window window
String saved = window.layout().value();
window.applyLayout(window.layout());ControlEvent.paneId() answers Optional<PaneId> and windowId()
Optional<WindowId>, where both answered Optional<String>; compare with a
handle's id() rather than its id().value(). The record gained a fourth
component, notification(), its typed reading; the three-argument constructor
remains and derives it.
// Given: Window window, ControlEvent event
event.windowId().filter(window.id()::equals).isPresent();Channel.await, Channel.awaitReservingCapacity and Channel.drain now declare
InterruptedException, as Pane.awaitText already did. An interrupted channel
wait used to raise an unchecked TmuxTransportException with the interrupt flag
left set, so cancelling one kind of wait and cancelling the other had to be
handled two different ways. Add throws InterruptedException at the call site,
or catch it and restore the flag.
// Given: Server server
Channel ready = server.channel("ready");
ready.signal(); // tmux keeps a signal nobody was waiting for, so this wait returns at once
try {
ready.await(java.time.Duration.ofSeconds(30));
} catch (InterruptedException cancelled) {
Thread.currentThread().interrupt();
}The return type changed. WakeReason.SIGNALLED became two answers, because a
screen wait can end a way a channel never does:
| Was | Is |
|---|---|
SIGNALLED |
TextOutcome.APPEARED when the wait saw the text arrive |
SIGNALLED |
TextOutcome.PRESENT_AT_ENTRY when the first look already showed it |
TIMED_OUT |
TextOutcome.TIMED_OUT |
SERVER_GONE |
TextOutcome.SERVER_GONE |
PRESENT_AT_ENTRY says only that the text was there on the first look. It may
be output from a command that finished before the wait read, or it may have been
on the pane for an hour; a screen cannot tell those apart. What it replaces is
worse: both used to be reported as though the wait had watched the text arrive,
so a marker left by an earlier run satisfied the next wait for it in
milliseconds. Treat both as "the text is there" unless the difference matters,
and when it does, append ; tmux wait-for -S name to your own command and block
on Server.channel, which is exact.
Pane.await(Predicate, Duration) still answers with WakeReason: you wrote that
condition and can test it before waiting, so the library is not the only thing
that can see it was already true.
A wait no longer matches the pane's echo of text sent through send, sendLine,
sendLiteral or paste, and now finds text the terminal wrapped across rows.
A caller that waited for a marker its own command line contained was being
answered by the echo; it now waits for the command to produce it. Waiting for
text identical to what was just typed cannot be distinguished from the echo and
will time out — send a marker the command prints, or signal a
Server.channel, which is exact.
The echo is taken out where it stands as a whole word, rather than by dropping
every row that mentions it: a command's own output routinely names the command,
and make answering make: *** No targets specified ... Stop. used to have its
answer dropped along with its question. id now comes off $ id and stays
inside uid=1000. The cost is that output repeating the typed text as a word of
its own loses that word — make: *** reads : *** to a wait — while its answer,
Stop., is still there. Pane.send(String) records an echo too, since tmux
types anything there that is not one of its key names.
TypedText is that record, and it is public because the wait is not the only
thing that needs it: take it once with TypedText.in(pane) and reuse it for
every look, since what a pane is holding ages out and a watcher that asked again
each time would stop discounting the echo partway through. Pane.noteTyped
tells it about keys that reached a pane some other way, which is what
synchronize-panes does.
Pane.capture() is unaffected and still answers with rows as the pane displays
them.
// Given: Pane pane
pane.sendLine("./build --target release");
// Answered by what the build prints, not by the echo of the line that started it.
pane.awaitText("release", java.time.Duration.ofSeconds(60));A directory name is bytes to tmux, and converting one this JVM cannot represent
throws — which, in a capture, lost every other pane over one pane's directory.
PaneState's currentPath component is now a String; a direct constructor
call passes the text rather than a Path.
Pane.currentPath() still answers with a Path, converting on request, and
throws LibTmuxException only when this JVM cannot represent that name.
Pane.currentPathText() is the value tmux reported and never throws.
// Given: Pane pane
pane.currentPathText().isEmpty(); // → falseclockMode, chooseTree, customizeMode, chooseBuffer, chooseClient, the
three findWindow overloads and FindSpec are gone. They open something for a
person at an attached client, and a program cannot observe what happens next. To
open one anyway, send tmux's own command; Pane.mode() still reports the mode
and exitMode() still leaves it. To find a window, filter Server.windows().
// Given: Pane pane
pane.server().cmd("choose-tree", "-t", pane.id().value());It parsed the name__contains=dev form Python libtmux takes as keyword
arguments, which is Python's calling convention rather than anything a Java
caller writes. Build a FilterExpr from the typed fields in code; for a filter
that arrives as text — a CLI flag, a config file, a stored query — read it with
FilterJson.readString from libtmux-jackson, which checks it against a model
and fails closed on any name the model did not declare.
Replace the old imports, catch types and calls, then recompile:
| Previous name | Current name |
|---|---|
ObjectDoesNotExist |
ObjectDoesNotExistException |
UnsupportedTmuxVersion |
UnsupportedTmuxVersionException |
server.raiseIfDead() |
server.requireAlive() |
Both exceptions still extend LibTmuxException. requireAlive() now throws
ServerNotRunningException, a LibTmuxException subtype, when no daemon
answers; it still preserves transport failures. The old names have no
forwarding aliases.
// Given: Server server
server.requireAlive();
server.isAlive(); // → trueLive listings, finders and snapshot capture throw when a read fails. An
absent daemon throws ServerNotRunningException; any other failed capture
throws LibTmuxException. A missing object in a successful capture still
produces an empty Optional.
These reads also distinguish absence from a failed read:
| Read | Was | Now |
|---|---|---|
Server.hasSession on an unreachable socket |
false |
LibTmuxException |
Options.get on an unreachable socket |
Optional.empty() |
LibTmuxException |
Server.listKeys (now Keys.list) on any failure |
List.of() |
raises |
listKeys with no daemon |
started one, listed its tables | ServerNotRunningException |
Server.requireAlive on an unreachable socket |
ServerNotRunningException |
LibTmuxException |
A missing session is still false, and an option tmux does not know is still
empty. Buffers.show and Buffers.delete also throw
ServerNotRunningException for an absent daemon instead of answering as
though nothing matched.
Catch ServerNotRunningException to start a daemon on demand, and
LibTmuxException for any other failed read; do not treat either as an empty
server. server.cmd(...) still returns a completed nonzero exit as result
data, while server.run(...) throws.
A finder requires a running daemon. server.newSession("build") can start
one; BuildAWorkspace shows how to handle both an
existing session and an endpoint with no daemon.
Error Prone flags discarded results from Session.rename, Window.rename,
Pane.retitle and entity refresh methods through @CheckReturnValue. Retain
the returned handle to read the updated capture; the original remains
unchanged.
// Given: Session session
Session renamed = session.rename("build");
renamed.name(); // → build0 meant two different things depending on release — no process at all, and,
on a development tmux, a process that ran and died — and could be mistaken
for a real pid either way. Replace pane.pid() > 0 with pane.pid() .orElseThrow() > 0, and a bare long pid = pane.pid(); with orElse(0) if a
sentinel is still wanted, or orElseThrow() where absence should fail loudly.
// Given: Pane pane
pane.pid().isEmpty(); // → falsePaneState's own pid component changed the same way, from long to
OptionalLong; a caller constructing one directly wraps the value in
OptionalLong.of(...) or passes OptionalLong.empty(). PaneState also
gained a position component (a PanePosition) between size and title —
a direct constructor call needs one more argument, in that position.
attach now sends refresh-client -f new-layouts right after attaching, so
a %layout-change notification agrees with what a plain client reads on tmux
3.8+ instead of carrying the classic string. A fake control-mode server built
for a test — one that scripts an exact sequence of requests and replies
rather than answering every request generically — now has one more request
to answer, right after the attach reply, before whatever its own script
expects next.
The server's launch options, tool names and resources changed in earlier alpha releases. A launcher configuration or client written for the old surface needs these changes.
--safetyandLIBTMUX_SAFETYare retired and fail startup. The oldreadonly,mutating, anddestructivevalues map toinspect;inspect,manage,execute; and all four toolsets, respectively. UseLIBTMUX_TOOLSandLIBTMUX_EXCLUDE_TOOLSfor exact exceptions.--watchandLIBTMUX_WATCHare retired and fail startup. MCP no longer sends dynamic resource notifications. Usewait_for_text,wait_for_channel, orcapture_since; Java applications can useControlClient.
tmux_capture_pane,tmux_capture_since,tmux_list_panes,tmux_list_sessions,tmux_list_windows,tmux_paste_text,tmux_resize_pane,tmux_search_panes,tmux_select_layout,tmux_send_keys,tmux_show_environment,tmux_show_hooks,tmux_signal_channel,tmux_wait_for_channel, andtmux_wait_for_textretain their suffix withouttmux_.list_panesno longer accepts a filter; filter its bounded metadata client-side. Usesearch_panesonly for displayed text.tmux_run,tmux_new_session,tmux_new_window,tmux_split_pane, andtmux_show_optionsbecomerun_shell_command,create_session,create_window,split_window, andshow_option, in the same order.tmux_whoamisplits intoget_server_infoand the caller marker fromlist_panes.tmux_renamebecomesrename_sessionorrename_window;tmux_selectbecomesselect_windoworselect_pane;tmux_killbecomeskill_session,kill_window, orkill_pane. Server termination is not exposed.list_windowsandshow_environmenttakesession_id, such as$1, orsession_name, in place ofsession.tmux_set_optionhas no generic equivalent. Migrate supported uses toset_mouse_enabled,set_history_limit,set_synchronize_panes, orset_pane_title.tmux_apply_workspacebecomes explicitcreate_session,create_window,split_window, andselect_layoutcalls followed byrun_shell_command,send_keys, orpaste_text.tmux_list_servers,tmux_list_clients, andtmux_drain_channelhave no direct equivalents. Each process pins one server, described byget_server_info;list_sessionsmarks attached sessions but exposes no client details; stale channel signals cannot be drained through MCP.
tmux://serverbecomesget_server_info; the static capability resource adds connection and selection provenance.tmux://sessionsandtmux://panesbecomelist_sessionsandlist_panes.tmux://sessions/{session_name}andtmux://panes/{pane_id}becomeget_session_infoandget_pane_info.tmux://panes/{pane_id}/contentbecomescapture_pane,snapshot_pane, orcapture_since. Usewait_for_textwhen the old subscription was waiting for a terminal condition.run_and_waitbecomes onerun_shell_commandcall.watch_until_readyuseswait_for_text, orsnapshot_panefollowed bycapture_sincewhen output must be carried across turns. After a timeout, continue from the returned cursor instead of restarting the observation.find_the_panecomposeslist_panes,search_panes,find_pane_by_position, andget_pane_infoas needed.build_workspacecomposes the create, split, layout, title, selection, and execution tools.clean_up_safelystarts withlist_panesto identify the MCP pane andlist_sessionsto identify attached sessions, then uses the specific pane, window, or session teardown tool. An attached session may have a person watching it, so apparently abandoned state may still be live.- Earlier live
completion/completesuggestions forpane_idhave no direct replacement. Calllist_panes, then pass the exact id through the typed tool schema.