LibTmux 0.0.0-alpha.7

Prefix Reserved
This is a prerelease version of LibTmux.
dotnet add package LibTmux --version 0.0.0-alpha.7
                    
NuGet\Install-Package LibTmux -Version 0.0.0-alpha.7
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LibTmux" Version="0.0.0-alpha.7" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LibTmux" Version="0.0.0-alpha.7" />
                    
Directory.Packages.props
<PackageReference Include="LibTmux" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LibTmux --version 0.0.0-alpha.7
                    
#r "nuget: LibTmux, 0.0.0-alpha.7"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LibTmux@0.0.0-alpha.7
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LibTmux&version=0.0.0-alpha.7&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=LibTmux&version=0.0.0-alpha.7&prerelease
                    
Install as a Cake Tool

LibTmux

A typed, async-first tmux client for .NET. Servers, sessions, windows, panes, clients, options, hooks and buffers, against every tmux from 3.2a to 3.7b, on net8.0 and net10.0.

Alpha. The public API is not settled and can change between prereleases without notice, so pin an exact version. The behaviour is proven against all seven supported tmux versions on every commit.

$ dotnet add package LibTmux --prerelease

One dependency: Microsoft.Extensions.Logging.Abstractions, which is interfaces with no implementation attached — a caller who wants no logging pays nothing for it.

Start here

using LibTmux;

Server server = await Server.ConnectAsync();
Session session = await server.CreateSessionAsync(new NewSessionRequest(name: "build"));
Window window = await session.CreateWindowAsync(new NewWindowRequest(name: "tests"));
Pane pane = (await window.GetPanesAsync())[0];

await pane.SendTextAsync("dotnet test");
await pane.EnterAsync();

To reach one server in particular:

Server elsewhere = await Server.ConnectAsync(
    new ServerConnectionOptions(socketName: "build-box"));

Where a bare connect lands

ConnectAsync with no arguments takes the first of these that says anything:

Source What it decides
ServerConnectionOptions an explicit socketPath, socketName, or socketNameFactory
LIBTMUX_SOCKET_PATH the socket, by path
LIBTMUX_SOCKET_NAME the socket, by name, under the root below
TMUX_TMPDIR the root a name resolves under — /tmp when unset
the socket named default

Options always win. A call that named a socket is never redirected by a variable, which is what makes the variables safe to export for a whole process — a test harness, a sandbox, a container — without auditing the call sites in between. This library's own examples use exactly that: each one exports a socket name of its own, so the connect above stays one line and still cannot reach the server you are sitting in.

A pane's own server is a different question, and a different call: Server.FromEnvironment() reads the socket path out of the TMUX variable tmux exports into every pane. ConnectAsync never consults it.

Every call that reaches tmux is asynchronous and takes a CancellationToken. There are no synchronous twins to choose between.

Three ways to reach tmux

Which one a call uses is visible where the call starts, and all three work on every supported tmux.

Mode Flip it on Dispatch What one more command costs
One-shot session.CreateWindowAsync(…) one command, awaited another process — ~2.3 ms
Control server.EnterControlModeAsync(ct) one client, streamed another round trip — ~0.2 ms
Chained server.Chain()…ExecuteAsync(ct) N batched, one invocation more bytes on one command line — ~0.02 ms

That is the marginal cost — fifty commands minus one, over forty-nine, as medians of 100 samples against tmux 3.7b — because it is the part that belongs to the library rather than to the machine. Absolute timings move by a factor of five on one host depending on what else it is doing. The recorded runs give the whole distribution with the tmux, host and date that produced it: github.com/libtmux/libtmux-dotnet/tree/master/docs/benchmarks.

// One command, a typed object back.
Window built = await session.CreateWindowAsync(new NewWindowRequest(name: "build"), ct);
// One client, held open, streaming what tmux does on its own.
await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);
IReadOnlyList<string> reply = await control.SendAsync("list-windows", ct);
// Many commands, one invocation, one process cost.
await server.Chain()
    .Then("new-window", "-d", "-n", "one")
    .Then("new-window", "-d", "-n", "two")
    .ExecuteAsync(ct);

Control mode is an order of magnitude cheaper per command; a chain wins for a batch by paying one round trip for the whole sequence.

Reading what is there

Accessors return IReadOnlyList<T> over an explicit read and never shell out while you enumerate them:

foreach (Window each in await session.GetWindowsAsync(ct))
{
    foreach (Pane every in await each.GetPanesAsync(ct))
    {
        Console.WriteLine($"{each.Name} {every.Index} {every.Width}x{every.Height}");
    }
}

A handle says what it read, and that stays true. Operations that change what an object is hand back a replacement:

Window renamed = await window.RenameAsync("integration", ct);

Asking tmux again is RefreshAsync. A whole hierarchy in one read is CaptureSnapshotAsync:

Server snapshot = await server.CaptureSnapshotAsync(SnapshotDepth.Panes, ct);

Running something, and reading it back

await pane.SendTextAsync("echo hello-from-libtmux", cancellationToken: ct);
await pane.EnterAsync(ct);

// tmux accepts a command before the shell has finished it, so the result is
// waited for rather than assumed.
string output = await TmuxWait.UntilAsync(
    async token => string.Join('\n', await pane.CaptureAsync(cancellationToken: token)),
    text => text.Contains("hello-from-libtmux", StringComparison.Ordinal),
    TimeSpan.FromSeconds(10),
    TimeSpan.FromMilliseconds(20));

Splitting and resizing

Pane split = await pane.SplitAsync(new SplitPaneRequest(direction: PaneDirection.Below), ct);
await split.SetHeightAsync(10, ct);

Options and hooks

tmux has no types, so a value carries the text it reported alongside the readings that text supports:

await window.Options.SetAsync(new SetOptionRequest("automatic-rename", "off"), ct);
TmuxOption option = (await window.Options.GetAsync(
    new GetOptionRequest("automatic-rename"), ct))[0];

Console.WriteLine($"{option.Value.Raw} flag={option.Value.Boolean}");

An option the window does not hold is inherited rather than missing. Hooks are arrays even with one entry:

TmuxHook hook = await server.Hooks.SetAsync(
    new SetHookRequest("alert-bell", "set-option -g @rang yes"), ct);

Filtering

Ordinary filtering is LINQ over what you read:

IReadOnlyList<Window> windows = await session.GetWindowsAsync(ct);
IEnumerable<Window> building = windows.Where(
    each => each.Name.StartsWith("build", StringComparison.Ordinal));

Declarative filtering translates an expression into a portable document, or throws — it never quietly falls back to filtering in memory. Write it over the objects you already hold:

IReadOnlyList<Session> sessions = await server.GetSessionsAsync(ct);
IReadOnlyList<Session> building = sessions.Matching<Session>(
    session => session.Name.StartsWith("build") && session.Attached);

Relations quantify, and the element type carries its own fields:

Server captured = await server.CaptureSnapshotAsync(SnapshotDepth.Windows, ct);
IReadOnlyList<Session> withBuild = captured.Sessions.Matching<Session>(
    session => session.Windows.Any(each => each.Name.StartsWith("build")));

The same expression is also a document, which can be written here and answered somewhere else:

QueryDocument document = QueryExtensions.Translate<Session>(
    session => session.Name.StartsWith("build") && session.Attached);

You write C# and tmux receives tmux. The catalog carries the pair for all twelve queryable fields — Session.Name is session_name, Client.IsControlClient is client_control — and it is closed:

Session Window Pane Client
Name, Id, Attached, Windows Name, Id, Panes Id, pane_command Name, IsControlClient, client_id

Two fields have no property on their entity, and are reached by declaring a row whose property names are the wire names — which is also how you query a projection rather than an entity:

internal sealed record PaneRow(string PaneId, string PaneCommand);

A field outside the catalog throws UnsupportedQueryExpressionException rather than falling back, so an expression that translates is one tmux can answer.

Put it on the wire with LibTmux.Query.Json.

Versions

Where a flag is missing on the running tmux, the request goes out without it and a warning says what was left off. Where a whole command is missing, nothing is sent and TmuxVersionTooLowException says which version would be needed.

// A handle says what it read: the version is what tmux reported when this
// server was reached, and null when it reported something unparsable.
TmuxVersion? version = server.Version;
Console.WriteLine($"tmux {version?.Raw} 3.4-or-newer={version?.IsAtLeast(TmuxVersion.Parse("3.4"))}");

Testing your own code

LibTmux.Testing ships in this package. It gives a test a tmux server of its own, on its own socket, killed deterministically:

using LibTmux.Testing;

TmuxTestFactory factory = new();
await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync();

await scope.Pane.SendTextAsync("echo hello");
await scope.Pane.EnterAsync();

Disposing kills the server, so a test that fails part way through leaves nothing behind.

Logging

Pass an ILogger when connecting and every tmux command is recorded once, at the single point they all pass through:

Server logged = await Server.ConnectAsync(new ServerConnectionOptions(logger: logger));

Commands are recorded at Debug and failures at Error, with stable scalar fields (TmuxSubcommand, TmuxExitCode) to filter on. Anything that can carry a payload is truncated, the command line included.

Knowing when a retry is safe

Retrying a failed command is the obvious recovery and it is only sound when the command never reached tmux. Every failure says which it was, so the decision is an exception filter rather than a guess:

try
{
    await server.CreateSessionAsync(new NewSessionRequest(name: "build"), ct);
}
catch (LibTmuxException error) when (error.Dispatch == TmuxDispatchState.NotDispatched)
{
    // tmux was never started, so nothing happened and this can be sent again.
    Console.WriteLine($"safe to retry: {error.Dispatch}");
}

NotDispatched is claimed only where the library can see that no tmux process ran — a missing binary, or a command rejected before launch. A client that started and then died is Unknown, because tmux may have acted before the pipe broke, and Unknown is the default for exactly that reason. A TmuxCommandException is always Dispatched: it exists because tmux answered.

Package Adds
LibTmux.Query.Json JSON for query documents
LibTmux.Workspace Sessions from tmuxp YAML
LibTmux.Mcp A Model Context Protocol server, as a .NET tool

Source, docs and issues: https://github.com/libtmux/libtmux-dotnet

What changed between versions: CHANGELOG

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on LibTmux:

Package Downloads
LibTmux.Workspace

Builds tmux sessions from tmuxp workspace files, on top of LibTmux.

LibTmux.Query.Json

System.Text.Json support for LibTmux query documents. The core library does not reference it, so a caller who does not want a JSON dependency does not get one.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.0.0-alpha.7 60 8/16/2026
0.0.0-alpha.6 54 8/16/2026
0.0.0-alpha.5 49 8/16/2026
0.0.0-alpha.4 57 8/16/2026
0.0.0-alpha.3 62 8/16/2026
0.0.0-alpha.2 61 8/15/2026
0.0.0-alpha.1 57 8/15/2026

Alpha. LibTmux.Mcp is a different server: 42 tools across three safety tiers, six tmux:// resources and four workflow prompts, where it had five tools. Every tool answers a typed record with a JSON output schema rather than prose, and the tool names all changed. Nothing polls — tmux_run reports the shell's real exit status, tmux_wait_for_text sleeps on tmux's control-mode stream, and tmux_start_job carries work that outlives one call. Every capture keeps the newest lines and reports what it dropped. Subscriptions and the Tasks extension are served on the current protocol revision. No change to LibTmux itself. The public API is not settled and may change without notice between prereleases; pin an exact version. Full history: https://github.com/libtmux/libtmux-dotnet/blob/master/CHANGELOG.md