@libtmux/workspace
v0.1.0-alpha.12
Published
Declarative tmux workspace builder, built on libtmux.
Downloads
831
Maintainers
Readme
@libtmux/workspace
Describe a tmux session as data; apply it. Applying twice converges rather than duplicating.
Part of libtmux for Bun and TypeScript. Built on
libtmux.
[!WARNING] Alpha. Prerelease software: the config shape and the exported functions can change between alpha releases without a deprecation cycle. Pin an exact version.
Install
$ bun add --exact @libtmux/[email protected] [email protected]$ npm i --save-exact @libtmux/[email protected] [email protected]$ pnpm add --save-exact @libtmux/[email protected] [email protected]$ yarn add --exact @libtmux/[email protected] [email protected]libtmux is a peer of this package in practice: you pass it the Server.
Requires Node 22+ or Bun 1.3.14+, and tmux 3.2a or newer.
This package and @libtmux/workspace-cli are two
implementations, not one product with two faces. They differ in what they do
with a session that already exists — this one converges it, the CLI compares it
against the document and refuses when they disagree — and in what a document may
say: the schema here is strict, so environment, before_script,
window_index, suppress_history, x- keys and the CLI's other fields are
rejected. A file written for the CLI is not necessarily one this accepts.
Linux is the only supported host for real tmux control. The macOS CI lane checks package artifacts without exercising tmux; macOS runtime behavior is unproven. WSL is untested.
Use it
import { Server } from "libtmux";
import { applyWorkspace } from "@libtmux/workspace";
const server = new Server();
await applyWorkspace(server, {
session_name: "api",
windows: [
{ window_name: "editor", panes: ["vim", "git status"] },
{
window_name: "server",
layout: "even-horizontal",
panes: [{ shell_command: "bun dev", focus: true }, "bun test --watch"],
},
],
});applyWorkspace resolves to the Session it built or converged.
Commands use create-only policy by default: shell_command and
shell_command_before run only in panes this apply created. Pass
{ commands: "always" } only for commands that are safe to repeat. This
package converges tmux topology; it does not monitor or restart processes.
Options named in the workspace are set on every apply. Removing an option from
the file does not unset it because tmux does not record which current value the
workspace owns. @libtmux-workspace is reserved for ownership bookkeeping and
is rejected in workspace options. Option names are literal; tmux format syntax
in a key is not expanded.
If tmux rejects a later operation, applyWorkspace throws
WorkspaceApplyError. Its frozen completed milestones name the high-level
work that finished, failed names the stage that stopped, and cause retains
the original error. requiresReplan is always true: callers must rediscover
tmux structure before deciding what to do next. It does not report whether pane
commands ran; mutations are not transactions, and transport failure may leave
delivery indeterminate.
applyWorkspace validates every window layout before claiming a session or
changing options. Names accept unique abbreviations supported by the running
daemon; classic layouts require a valid checksum. Classic and JSON layouts
require a nonempty tree and enough pane cells; JSON requires tmux 3.8 or newer.
A window is rebalanced between splits, because halving one pane in turn
runs out of room by the fifth at a default 80x24, so a window that names no
layout — or names the empty string — comes up tiled. A declared layout is
applied last and has the final say. tmux remains responsible for geometry and
pruning.
The library limits custom layouts to 8192 characters and classic layouts to
256 nested groups. JSON layouts share tmux's limit of 200 nested objects.
The shape
Config is tmuxp-shaped, so the field names are
the ones people already know — session_name, windows, panes,
shell_command, shell_command_before, start_directory, layout, focus,
options.
| Field | On | Effect |
| ---------------------- | ----------------------- | ----------------------------------------------- |
| session_name | workspace | The session to build or converge |
| start_directory | workspace, window, pane | Working directory, inherited downward |
| options | workspace, window | String, finite number, or boolean tmux values |
| window_name | window | Renamed if it differs |
| layout | window | Applied once the pane count settles |
| panes | window | A string is shorthand for { shell_command } |
| shell_command | pane | One command or a list, sent in order |
| shell_command_before | window | Run in every pane of the window, before its own |
| focus | window, pane | Selected last, so the final one wins |
Because the config is data, it can come from a YAML file, an HTTP request, or a
literal. parseWorkspace validates one you already parsed; parseWorkspaceYaml
is a convenience that needs Bun's YAML parser and says so when it is missing.
start_directory resolves pane, then window, then workspace. The same order
applies to the first pane tmux creates with the session.
Converging, not just creating
Two details that are easy to get wrong and are handled here:
No stray leading window. tmux gives every new session a window and every new window a pane, so the first of each is adopted rather than created. Creating them anyway is the classic workspace-builder bug.
Position, not index. tmux window indexes are not positions — base-index
shifts them and a killed window leaves a gap — so a window is matched by
ordinal.
It prunes what it built, not what it found. A session name is a lookup, not
a claim: dev in this file and dev you started by hand are the same name. So
a session this package creates is stamped with a tmux user option, and surplus
windows and panes are removed only from a session carrying that stamp. Against
one it merely found, applying converges additively and leaves the rest alone.
Pruning follows tmux's sharing model. An independently linked surplus window is unlinked only from this session. A grouped session shares one window list, so its surplus windows are retained. Surplus panes are also retained when their window has another placement because killing a pane changes every placement.
Ask before applying, which is the question a converging tool cannot answer afterwards:
import { planWorkspace } from "@libtmux/workspace";
const desired = { session_name: "api", windows: [{ panes: ["vim"] }] };
const plan = await planWorkspace(server, desired);
plan.owned; // false for a session this workspace did not create
plan.removesWindows; // ID-keyed kill or unlink actions
plan.removesPanes; // exact pane IDs, grouped by their window placement
plan.renamesWindows; // an array, so duplicate current names stay distinct
plan.retains; // surplus plus the policy or sharing reasonPlan entries are frozen data carrying tmux IDs, indexes, positions, and names.
The plan covers session, window, and pane membership, not options, layouts,
focus, or pane commands. It is advisory: acquire a fresh one after any delay or
failed apply because tmux structure may have changed.
Planning accepts prune; command policy is apply-only.
prune decides the rest. "owned" is the default above; "never" removes
nothing; "always" authorizes this one plan or apply to remove surplus from a
session the workspace did not create:
await applyWorkspace(
server,
{ session_name: "api", windows: [{ panes: ["vim"] }] },
{ prune: "always" },
);"always" does not claim the session or stamp persistent ownership. A later
apply using the default "owned" policy leaves new surplus alone.
Two applyWorkspace calls racing on the same session name are not
coordinated: tmux offers no lock on a session that does not exist yet, so
either can observe no session and both attempt to create one, or one apply's
converge can run against a session mid-build by the other. Serialize calls
that target the same name.
A worked example
examples/workspace/workspace.ts calls
applyWorkspace with a declared layout, inspects the resulting session, and
tears it down while treating "already gone" as an answer. The integration suite
runs it against a real tmux server.
