Skip to content

About

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

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1,451 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

libtmux for C#

libtmux for C#

dotnet LibTmux libtmux.org license

Drive tmux from C#: servers, sessions, windows, panes, clients, options, hooks and buffers, typed and asynchronous, for tmux 3.3a and newer on net8.0 and net10.0.

Run a command and read its exit status, find and filter what a server holds, type into a pane and wait for the answer, and choose how a call reaches tmux.

Add it to your project:

$ dotnet add package LibTmux

Contents — Quick start · Is this for you? · Packages · Find things · Run a command · Panes · Filter · Options · Versions · Choose a path · Testing · Workspaces · Assistants · Leaves out · Support

Quick start

Start a tmux server of your own, make a session, a window, and a pane, and type into the pane:

using LibTmux;

// A tmux server of its own on the socket "quickstart". Disposing the
// scope stops it, so nothing is left running.
await using OwnedServerScope owned = await Server.CreateOwnedAsync(
    new ServerConnectionOptions { SocketName = "quickstart" });
Server server = owned.Value;
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");
Console.WriteLine($"{session.Name}:{window.Name}");

Make a console project, then paste the program into Program.cs:

$ dotnet new console --output demo && \
    cd demo && \
    dotnet add package LibTmux

Run it with tmux 3.3a or newer on PATH:

$ dotnet run

It prints build:tests. Server.CreateOwnedAsync starts the server on its own socket and stops it when the scope is disposed; Server.ConnectAsync attaches to one that is already running.

The csharp run blocks below continue from this program: server, session, window, and pane are its variables, and ct is a CancellationToken.

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 and draws nothing), full Windows support, or a process launcher: Process.Start is right there. Native Windows has a bounded query-only Psmux* preview.

What you get over a shell wrapper: typed entities with real IDs, one dispatch model per workload, and a version model that names the flag a tmux lacks.

Packages

Package Add it when
LibTmux LibTmux Always. The client. One dependency: logging abstractions.
LibTmux.FSharp LibTmux.FSharp You write F# and want curried task helpers, seq queries, and typed portable filters over the core values.
LibTmux.Query.Json LibTmux.Query.Json You send queries between processes and want them as JSON.
LibTmux.Testing LibTmux.Testing You test code that drives tmux and want scopes that clean up after themselves.
LibTmux.Extensions.DependencyInjection LibTmux.Extensions.DependencyInjection Your application composes services and wants a tmux handle injected.
LibTmux.Workspace LibTmux.Workspace You have tmuxp YAML to build from.
LibTmux.Workspace.Cli LibTmux.Workspace.Cli You want tmux-workspace: load, freeze and convert tmuxp files from a shell. Installs as a tool.
LibTmux.Mcp LibTmux.Mcp You want an assistant driving tmux. Installs as a tool.
$ dotnet add package LibTmux
  • One dependency. The core takes the logging abstractions. YAML, JSON and MCP ship as their own packages.
  • One version. Every package carries it, so a LibTmux.Workspace goes with the LibTmux of the same version.
  • F#. LibTmux.FSharp keeps the core handles and adds curried task helpers, seq queries and portable filters. Start with its quick start.

Find sessions, windows, and panes

Session.GetWindowsAsync: windows and panes

One call reads a session's windows, and Window.GetPanesAsync reads a window's panes. They return IReadOnlyList<T> and run no tmux command while you enumerate them:

using LibTmux;

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

It prints one line per pane: the window name, the pane index, and its size.

Window.RenameAsync: a changed copy

A handle holds what tmux said when it was read. An operation that changes an object returns a replacement, and Pane.RefreshAsync asks tmux again:

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

It prints the old name and the new one.

A replacement is still the same window: identity is the server generation plus the typed ID, not the name.

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

// Identifiers order the way tmux issued them, oldest first.
IEnumerable<Window> oldest = (await session.GetWindowsAsync(ct)).OrderBy(each => each.Id);
Console.WriteLine(string.Join(", ", oldest.Select(each => each.Id)));

It prints True, both names, and the window IDs in the order tmux issued them.

Run a command and read what it printed

Pane.RunAsync: a command's exit status

It runs a shell command in a pane and returns its real exit status and the lines it printed. A command that exits nonzero returns normally with that status:

using LibTmux;

PaneRunResult result = await pane.RunAsync("echo hello; exit 3", TimeSpan.FromSeconds(10), ct);
if (result.ExitStatus != 3 || !result.Output.Contains("hello"))
{
    throw new InvalidOperationException("Expected exit status 3 and the line hello.");
}

It prints nothing and throws if the status or the line is wrong.

TmuxWait.UntilAsync: wait for what a command prints

For input a shell command cannot carry, type it and wait. tmux accepts a command before the shell has finished it, so the example waits for the result:

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

It returns once the pane shows the line.

Split, resize, and close panes

Pane.SplitAsync: a new pane beside this one

Window.ResizeAsync and Pane.ResizeAsync set a size. Type into the new pane with Pane.SendTextAsync and read it with Pane.CaptureAsync:

using LibTmux;

Window wide = await window.ResizeAsync(new ResizeWindowRequest { Width = 120, Height = 30 }, ct);
Pane right = await pane.SplitAsync(new SplitPaneRequest { Direction = PaneDirection.Right }, ct);
Pane narrow = await right.ResizeAsync(new ResizePaneRequest { Width = "30" }, ct);

await narrow.SendTextAsync("echo $((6*7))", cancellationToken: ct);
await narrow.WaitForTextAsync("42", TimeSpan.FromSeconds(10), ct);
string screen = string.Join('\n', await narrow.CaptureAsync(cancellationToken: ct));

Console.WriteLine($"{wide.Width}x{wide.Height} {narrow.Width} {screen.Contains("42")}");
await narrow.KillAsync(cancellationToken: ct);

It prints the window size, the pane's width, and whether the capture holds 42.

Filter what you read

Plain LINQ

To filter what you already read, use LINQ:

using LibTmux;

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

QueryExtensions.Matching: declarative filters over what you hold

It turns an expression into a portable document, or throws; it never quietly falls back to filtering in memory:

using LibTmux;
using LibTmux.Query;

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

QueryExtensions.Translate: the same filter as a document

The expression is also a document, written here and answered somewhere else. LibTmux.Query.Json puts it on the wire:

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

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

It prints the document's target, Session.

  • The document uses stable wire names: Session.Name is session_name and Client.IsControlClient is client_control_mode. The catalog carries all twelve queryable fields.
  • A field outside the catalog throws. UnsafeTmuxFilter is the separate opt-in for native tmux -f filters.

QueryPlan<T>: what runs in tmux, what runs here

QueryExtensions.Plan splits a filter against the tmux version Server.InspectAsync reports: an exact leading part runs inside tmux and the rest runs locally. The plan says which, and it runs when you ask:

Server inspected = await server.InspectAsync(ct)
    ?? throw new InvalidOperationException("The tmux daemon is absent.");
QueryDocument predicate = QueryExtensions.Translate<Window>(
    candidate => candidate.IsActive && candidate.Name.StartsWith("build", StringComparison.Ordinal));
QueryPlan<Window> plan = predicate.Plan<Window>(inspected.DaemonVersion!.Value);
QueryResult<Window> result = await plan.ExecuteAsync(inspected, ct);

Console.WriteLine($"{result.Count} matches from {result.Snapshot.Windows.Count} placements");
foreach (string reason in plan.FallbackReasons)
{
    Console.WriteLine(reason);
}

It prints the match count and why anything stayed local.

Change options and hooks

TmuxOptions and TmuxHooks: settings and callbacks

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:

using LibTmux;

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

It prints the raw value, its flag reading, and its number reading.

An option the window does not hold reads as the inherited value. Hooks are 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);

Check the tmux version

TmuxVersionTooLowException: a command the running tmux lacks

A flag a tmux lacks is left off and a warning names it. A command it lacks sends nothing, and this exception names the version that would be needed:

using LibTmux;

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

It prints the version tmux reported and whether it is 3.4 or newer.

Choose how to reach tmux

Which one a call uses is visible where the call starts. One-shot calls and chains work on every supported tmux; explicit control mode needs tmux 3.7+.

Mode Flip it on Dispatch What one more command costs
One-shot session.CreateWindowAsync(…) one command, awaited another round trip on a shared client; a process for the few commands that cannot use it
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

Each figure is the marginal cost of one more command. Timings move with the machine: the recorded run has the distribution and the host, and Choosing a mode has allocations.

Session.CreateWindowAsync: one command, a typed object back

using LibTmux;

// One-shot: a command, a typed object back.
Window built = await session.CreateWindowAsync(new NewWindowRequest { Name = "build" }, ct);
Console.WriteLine(built.Name);

Server.EnterControlModeAsync: one client, held open

// 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(
    TmuxCommand.Create("new-window", "-d", "-n", "build"),
    ct);

IControlModeSession.Events: what tmux says unasked

Send a command, then read the notification it causes. The same stream reports pane output and the end of the session. More: Control mode.

using LibTmux;

await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);

await control.SendAsync(TmuxCommand.Create("new-window", "-d", "-n", "build"), ct);

await foreach (TmuxEvent observed in control.Events.WithCancellation(ct))
{
    if (observed is TmuxNotificationEvent { Name: "window-add" } added)
    {
        Console.WriteLine($"window-add {added.Arguments[0]}");
        break;
    }
}

It prints window-add and the new window's ID.

Server.Chain: many commands, one invocation

// Chained: many commands, one invocation.
await server.Chain()
    .Then("new-window", "-d", "-n", "build")
    .Then("new-window", "-d", "-n", "test")
    .ExecuteAsync(ct);

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

Disposal attempts to stop the owned server even when the test body throws. TmuxWait.UntilAsync, in the client package, waits for a state that production code also needs when it reads back what a command produced.

Workspaces from the command line

LibTmux.Workspace.Cli installs tmux-workspace, which loads, captures and converts tmuxp workspace files:

$ dotnet tool install --global LibTmux.Workspace.Cli

With a workspace file saved as ~/.tmuxp/myproject.yaml, such as the example in the package README, build it without attaching, on the named tmux socket demo:

$ tmux-workspace load -d -L demo myproject

List the workspaces it can find:

$ tmux-workspace ls

An assistant on your terminal

LibTmux.Mcp is a Model Context Protocol server, installed as a .NET tool:

$ dotnet tool install --global LibTmux.Mcp
{ "mcpServers": { "tmux": { "command": "libtmux-mcp" } } }
  • Tools come in inspect, manage, execute, and teardown sets, and teardown is off by default.
  • run_shell_command returns the shell's real exit status.
  • wait_for_text wakes from tmux's control-mode stream on tmux 3.7 and newer, and polls on older releases.
  • capture_pane reads visible text and scrollback; snapshot_pane, search_panes, and capture_since give structured and incremental reads. No tool returns unbounded output.
  • Pane modes belong to attached humans: the server never enters one, and input refuses a pane that is in one.
  • With no socket pinned, tmux picks the server: the one in TMUX, otherwise its default socket. LIBTMUX_SOCKET or LIBTMUX_SOCKET_PATH pins one.
  • More: every tool, generated from the server itself, and the full instructions.

A client starts the server itself and speaks to it over standard input and output. Register it with Claude Code:

$ claude mcp add tmux -- libtmux-mcp

A workspace that tmux-workspace load -L demo built lives on the socket demo. To let a client read it and change nothing, name the socket and select the inspect toolset:

$ claude mcp add tmux \
    -e LIBTMUX_SOCKET=demo \
    -e LIBTMUX_TOOLSETS=inspect \
    -- libtmux-mcp

A client that reads a JSON configuration takes the command and the same settings as env:

{
  "mcpServers": {
    "tmux": {
      "command": "libtmux-mcp",
      "env": { "LIBTMUX_SOCKET": "demo", "LIBTMUX_TOOLSETS": "inspect" }
    }
  }
}

Package directories

LibTmux: the client

Servers, sessions, windows, panes, and buffers, typed and asynchronous, with one dependency: the logging abstractions.

Reference: libtmux.org and NuGet. Changelog: CHANGELOG.md.

LibTmux.FSharp: F# helpers over the core

Curried task helpers, seq queries, and typed portable filters over the core handles. Start with its quickstart.

Reference: libtmux.org, guides, and NuGet. Changelog: CHANGELOG.md.

LibTmux.Query.Json: query documents as JSON

A filter written in one process can be evaluated in another; the core does not reference System.Text.Json.

Reference: QueryJson and NuGet. Changelog: CHANGELOG.md.

LibTmux.Testing: a tmux server for each test

Scoped servers, sessions, and windows that clean up after themselves.

Reference: TmuxTestFactory, the testing guide, and NuGet. Changelog: CHANGELOG.md.

LibTmux.Extensions.DependencyInjection: a Server for dependency injection

AddLibTmux registers a Server as a singleton and binds ServerConnectionOptions through IOptions<T>.

Reference: Server, the package README, and NuGet. Changelog: CHANGELOG.md.

LibTmux.Workspace: sessions from tmuxp files

Parses tmuxp YAML or JSON and builds sessions, in one call or from a plan you review first.

Reference: WorkspaceBuilder, the reference index, and NuGet. Changelog: CHANGELOG.md.

LibTmux.Workspace.Cli: the tmux-workspace command

Loads, captures, and converts tmuxp workspace files from a shell.

Reference: the command reference and NuGet. Changelog: CHANGELOG.md.

LibTmux.Mcp: a tmux server for assistants

A Model Context Protocol server that gives an assistant hands on tmux; it installs as a .NET tool.

Reference: the MCP guide, the tool reference, and NuGet. Changelog: CHANGELOG.md.

What it leaves out

  • Live objects. A handle holds what tmux said when you asked. Pane.RefreshAsync asks again.
  • Synchronous calls. Every call that reaches tmux is asynchronous.
  • Silent fallbacks. Declarative filtering throws on an expression it cannot translate. A flag the running tmux lacks is left off, and a warning names it.
  • YAML, JSON, and MCP in the core. Workspace files, query documents on the wire, and the MCP server are separate packages, so a caller who does not want them never sees their dependencies.
  • A terminal emulator. This drives tmux and draws nothing.
  • Native Windows. PsmuxServer and its siblings read one psmux session and cannot create or change anything. See the preview.

Support and status

  • Pre-1.0. Any release may change or remove an exported identifier. Each package's changelog marks its breaks, so pin an exact version.
  • tmux. 3.3a or newer, tested through 3.8. A release candidate is not supported: see Release candidates. TmuxVersion orders X.Y-rcN below X.Y and promises nothing more.
  • .NET. net8.0 and net10.0.
  • Systems. Linux, including WSL. macOS is best effort. Native Windows has only the read-only Psmux* preview, also best effort.
  • Trimming and NativeAOT. The LibTmux core is analyzed for both. Compile and Matching resolve properties by name, so they warn trimmed callers to preserve the filtered types' public properties.

Documentation

AI policy

Use these packages with AI tools as you like. When contributing, check what you submit and wait for a maintainer's invitation before preparing a pull request. See the AI policy, also kept as AI_POLICY.md.

Attribution

Please use the following BibTeX template to cite libtmux-dotnet in scientific discourse:

@misc{libtmux-dotnet,
   author = {Tony Narlock},
   year = {2026},
   note = {https://libtmux.org/en/csharp/},
   title = {libtmux-dotnet: C\# wrapper for tmux}
}

License

MIT. Practical parity with Python libtmux, rewritten in C#.

About

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

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages