Skip to content

Latest commit

 

History

History
946 lines (769 loc) · 48 KB

MIGRATION.md

File metadata and controls

946 lines (769 loc) · 48 KB

Migration notes

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.

Next release

A breaking type is named on its own api-break: line. Mentioning the type in the prose is not that line.

Create Clojure resources through ownership APIs

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.

Output subscriptions bound payload bytes

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.

With no socket chosen, tmux picks the server

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.

0.0.1-alpha.19

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 needs tmux 3.7 and a pinned socket

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.

Python workspace commands need optional-policy version 2

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.

A pane killed mid-wait answers PANE_GONE

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.

Optional tmux requirements

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.

0.0.1-alpha.18

tmux 3.3a is the minimum

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.

Version ordering and parsing

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.

Version ordering and parsing

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.

0.0.1-alpha.16

CommandResult.stdout() keeps carriage returns

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();

libtmux-kotlin's subsystem accessors answer wrapper classes too

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.

libtmux-scala-cats's subsystem accessors answer F[_] wrapper classes too

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[_].

0.0.1-alpha.15

JDK 25 is the floor

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.

Client.refresh() returns the client or throws

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.

Server.setMouseEnabled is gone

api-break: Server

Write the option instead:

// Given: Server server
server.globalOptions().set("mouse", "on");

libtmux-kotlin is wrapper classes now, not extensions on the Java types

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")
}

An EventSubscription has one reader

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.

Failures are one sealed tree in io.github.libtmux.exception

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";
        };
    }
}

Notification has four more cases

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.

Kotlin reads core collections as read-only

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.

An attachment that skips the incarnation check says so

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.

EventSubscription.next returns a gap before the events that remain

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.

Prompt history is server.prompt()

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().

search_panes reports truncated, not limited

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.

0.0.1-alpha.14 and earlier

A tmux release candidate keeps its name and counts as its release

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.

Non-ASCII text reaches tmux on any locale

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.

ServerSnapshot.of needs the server's identity

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());

Key bindings are a view: server.keys()

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");

Window.layout() answers a WindowLayout

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 names panes and windows with their own types

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();

A cancelled channel wait throws InterruptedException

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();
}

Pane.awaitText answers with TextOutcome, not WakeReason

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.

Pane.awaitText ignores an echo of what this library typed

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));

PaneState.currentPath is text

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();      // → false

The interactive chooser methods on Pane are removed

clockMode, 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());

LegacyFilters is removed

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.

Exception and guard names

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();                      // → true

Failed live reads

Live 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.

Discarded replacement handles

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();                        // → build

Pane.pid() returns OptionalLong

0 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();                  // → false

PaneState'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.

ControlClient.attach requests JSON layouts on connect

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.

libtmux-mcp: toolsets, tool names and resources replace the earlier surface

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.

Launch options

  • --safety and LIBTMUX_SAFETY are retired and fail startup. The old readonly, mutating, and destructive values map to inspect; inspect,manage,execute; and all four toolsets, respectively. Use LIBTMUX_TOOLS and LIBTMUX_EXCLUDE_TOOLS for exact exceptions.
  • --watch and LIBTMUX_WATCH are retired and fail startup. MCP no longer sends dynamic resource notifications. Use wait_for_text, wait_for_channel, or capture_since; Java applications can use ControlClient.

Tool names

Resources, prompts and completion

  • tmux://server becomes get_server_info; the static capability resource adds connection and selection provenance.
  • tmux://sessions and tmux://panes become list_sessions and list_panes.
  • tmux://sessions/{session_name} and tmux://panes/{pane_id} become get_session_info and get_pane_info.
  • tmux://panes/{pane_id}/content becomes capture_pane, snapshot_pane, or capture_since. Use wait_for_text when the old subscription was waiting for a terminal condition.
  • run_and_wait becomes one run_shell_command call.
  • watch_until_ready uses wait_for_text, or snapshot_pane followed by capture_since when output must be carried across turns. After a timeout, continue from the returned cursor instead of restarting the observation.
  • find_the_pane composes list_panes, search_panes, find_pane_by_position, and get_pane_info as needed.
  • build_workspace composes the create, split, layout, title, selection, and execution tools.
  • clean_up_safely starts with list_panes to identify the MCP pane and list_sessions to 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/complete suggestions for pane_id have no direct replacement. Call list_panes, then pass the exact id through the typed tool schema.