Skip to content

Repository files navigation

libtmux for .NET

LibTmux downloads build tmux 3.2a – 3.7b license

Drive tmux from .NET. Servers, sessions, windows, panes, clients, options, hooks and buffers, typed and asynchronous, against every tmux from 3.2a to 3.7b on net8.0 and net10.0.

Alpha. Releases carry an -alpha prerelease tag. The API is not settled, and any release may change or remove exported identifiers without a deprecation period. Pin an exact version. Not recommended for production.

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

Is this for you?

Yes, if you want to drive a real terminal from code — build a dev environment, script a workspace, harness a TUI in tests, or give an assistant hands on a terminal.

Look elsewhere if you want a terminal emulator (this drives tmux, it does not draw), unrestricted Windows parity, or a process launcher — Process.Start is right there. Native Windows has a bounded, query-only Psmux* preview.

What you get that a shell wrapper does not: typed entities with real IDs, one dispatch model per workload (below), a version model that tells you when a flag does not exist on the tmux you are on rather than failing oddly, and documented ordinary-tmux examples that are executed against live tmux in CI.

Packages

Package Add it when
LibTmux v Always. The client. One dependency: logging abstractions.
LibTmux.Query.Json v You send queries between processes and want them as JSON.
LibTmux.Workspace v You have tmuxp YAML to build from.
LibTmux.Mcp v You want an assistant driving tmux. Installs as a tool, not a reference.
$ dotnet package add LibTmux --prerelease

The core takes exactly one dependency. Anything that would add another ships as its own package, so a caller who does not want YAML never sees YamlDotNet. They all carry one version, so any LibTmux.Workspace goes with the LibTmux of the same version, without a table to consult.

Three ways to reach tmux

Which one a call uses is visible where the call starts — never a flag buried in options — 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, which is the part that is a property of the library rather than of the machine: the difference between fifty commands and one, divided by forty-nine, as medians of 100 samples against tmux 3.7b. The absolute numbers move — a tmux process start measured 2.4 ms and 19 ms on the same machine the same day — so the recorded run gives the whole distribution, the tmux, the host and the date, and docs/benchmarks says which parts of it travel to your machine and which do not.

The same window, three ways:

// One-shot: a command, a typed object back.
Window built = await session.CreateWindowAsync(new NewWindowRequest(name: "build"), ct);
Console.WriteLine(built.Name);
// Control mode: one client, held open, streaming what tmux does.
await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);
IReadOnlyList<string> reply = await control.SendAsync("new-window -d -n build", ct);
// Chained: many commands, one invocation.
await server.Chain()
    .Then("new-window", "-d", "-n", "build")
    .Then("new-window", "-d", "-n", "test")
    .ExecuteAsync(ct);

Read the crossovers, not the numbers: control mode is an order of magnitude cheaper per command because its client is already running, while a chain beats it for a batch by paying one round trip for the whole sequence. Choosing a mode has allocations and how to rerun the table yourself.

Reading what is there

Accessors return IReadOnlyList<T> over an explicit read, and never shell out while you enumerate them — a foreach cannot surprise you with a tmux command per item.

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

A handle says what it read, which stays true. Operations that change what an object is hand back a replacement rather than mutating what you hold:

Window renamed = await window.RenameAsync("integration", ct);
Console.WriteLine($"{window.Name} -> {renamed.Name}");

Asking tmux again is RefreshAsync.

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

Filtering

Ordinary filtering is LINQ over what you already read:

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

Declarative filtering is different: it turns 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"));

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

Console.WriteLine(document.Target);   // Session

You write C# and tmux receives tmux: Session.Name goes on the wire as session_name, and Client.IsControlClient as client_control. The catalog carries that pair for all twelve queryable fields, and it is closed — a field outside it throws UnsupportedQueryExpressionException rather than falling back, so an expression that translates is one tmux can answer. LibTmux.Query.Json puts the document on the wire.

Options and hooks

Every object reaches the option table tmux keeps for it. 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} number={option.Value.Integer}");

An option the window does not hold is inherited rather than missing, and hooks work the same way — arrays even with one entry:

TmuxHook hook = await server.Hooks.SetAsync(
    new SetHookRequest("alert-bell", "set-option -g @rang yes"), ct);
Console.WriteLine(hook.Values[0].Command);

Versions

tmux grew flags across the supported range. Where a flag is missing, the request still 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"))}");

Every difference between 3.2a and 3.7b is recorded with the test that proves it, and dotnet-tmux.yml builds all seven from source on every commit.

Testing your own code

LibTmux.Testing 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. TmuxWait.UntilAsync waits for a state rather than sleeping, which is what keeps tmux tests from being timing-dependent.

An assistant on your terminal

LibTmux.Mcp is a Model Context Protocol server. It is a separate package and installs as a .NET tool, not a library reference:

$ dotnet tool install --global LibTmux.Mcp --prerelease
{ "mcpServers": { "tmux": { "command": "libtmux-mcp" } } }

It exposes 42 tools across three safety tiers, four fixed tmux:// resources, two resource templates and four workflow prompts — the full reference is generated from the server itself. Pass a socket name as its first argument to drive a server other than the ambient one, which is what a sandbox wants.

What it is built around is that an assistant should never get stuck and never waste context. tmux_run returns the shell's real exit status, tmux_start_job hands back a handle for work that takes minutes, and tmux_wait_for_text normally wakes from tmux's control-mode stream, with a bounded polling fallback when that stream cannot start. Nothing returns unbounded output: every capture keeps the newest lines and reports what it dropped. LIBTMUX_SAFETY decides which tier is registered, and a tool above it never reaches the model's list. Full instructions.

Documentation

Compatibility

tmux 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b
.NET net8.0, net10.0
OS Linux, macOS. The bounded Psmux* native-Windows and WSL query preview is experimental; its release gate runs both paths on net8.0 and net10.0
Trimming / NativeAOT LibTmux core is analyzer-gated and its smoke app is published and run for linux-x64 on net8.0 and net10.0. That proof does not cover the other packages, macOS, or native Windows/psmux

License

MIT. Practical parity with Python libtmux, rewritten for .NET.

About

Alpha: a typed, async-first .NET client for tmux 3.2a-3.7b. Practical parity with Python libtmux, native to C#. Ships an MCP server for AI agents.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages