MuxAdapter
MuxAdapter is the contract the whole library exists to provide: one set of verbs that means the
same thing on tmux, rmux, herdr, WezTerm, Zellij, cmux, and otty. You rarely touch it directly — you resolve a
MuxSession for the multiplexer you are inside, with its Exec already bound, and call its methods.
Import from the main entry:
import { resolveMux, type MuxSession, type OpenedPane,} from 'cyber-mux'Resolving a session
Section titled “Resolving a session”resolveMux(env, deps?) → MuxSession
Section titled “resolveMux(env, deps?) → MuxSession”Run the probe, pick the matching adapter (tmux / rmux / herdr /
wezterm / zellij / cmux / otty), and return it as a MuxSession with Exec bound. Throws if the process is in no
supported multiplexer.
const mux = resolveMux(process.env)mux.name // 'tmux' | 'rmux' | 'herdr' | 'wezterm' | 'zellij' | 'cmux' | 'otty'deps.exec (default nodeExec) is both the runner the detection probe uses AND the default every
session method binds. Gate on probeMultiplexer(env).mux !== 'none' first if a caller runs
with-or-without a multiplexer — see Detection.
A test binds a fake once instead of a real backend:
const mux = resolveMux(process.env, { exec: fakeExec })mux.callerPane()
Section titled “mux.callerPane()”This process’s own pane, as a MuxTarget the session can address — the value you pass as
open’s from so a pane:* split lands on the caller rather than on whichever
pane the user happens to be looking at.
Returns undefined when this session is in no pane, or in a pane belonging to a different
multiplexer than the session drives — in which case a pane:* open falls back to the backend’s own
default rather than splitting a foreign pane id.
The deps override
Section titled “The deps override”Every MuxSession method takes a trailing, optional MuxDeps:
export interface MuxDeps { exec?: Exec}Omit it in the common case — the method runs with the Exec bound at resolveMux. Pass { exec }
for a one-off override (a recording fake in a test, a decorated runner) without re-resolving the
session:
mux.open(opts, { exec: fakeExec })Opening panes
Section titled “Opening panes”mux.open(opts, deps?) → OpenedPane
Section titled “mux.open(opts, deps?) → OpenedPane”Create a pane, tab, or workspace and return its handle plus the workspace it landed in. The at
placement decides which:
at |
Opens |
|---|---|
'tab' (default) |
A new tab in the current (or within) workspace. |
'pane:right' / 'pane:down' |
A split of the from pane. |
'pane:float' |
A floating pane above the layout, resizing nothing. tmux 3.7+ and zellij only — wezterm, rmux, herdr, cmux, and otty throw FloatingPanesUnsupportedError; ask adapter.canFloatPanes first. |
'workspace' |
A genuinely separate workspace/session, leaving the caller’s untouched. |
Key MuxOpenOptions fields:
cwd(required) — working directory the new pane starts in.launch— command line to run inside it; omit for a blank shell.from— the pane apane:right/pane:downplacement splits (and, forpane:float, the pane whose region the float opens over). Pass it — omitting it does not mean “the caller”, it means “whatever this backend defaults to”, and the two backends default to opposite panes. Usemux.callerPane().within— the workspace atabplacement opens inside (aworkspacevalue from a prior open).ratio— fraction of the split kept by the original pane (0 < ratio < 1); the adapter handles each backend’s opposite sign convention for you. Dropped on'pane:float', which takes no share of the region and so has no original pane to size against.env— variables set at the new space’s birth, split or not.label— a name for the space at birth, at whatever tieratopens.workspaceGroup— an opaque group id for a backend with no workspace tier to group opened spaces under; routed throughgroup.
OpenedPane carries id (the pane), tab (always present — every multiplexer has a tab tier), and
workspace (absent on a backend, like tmux, with no workspace tier).
const pane = mux.open({ cwd: process.cwd(), at: 'pane:right', from: mux.callerPane(), launch: 'claude',})Naming and grouping
Section titled “Naming and grouping”mux.rename(target, tier, name, deps?)— name an already-open space at'pane'or'tab'. This is the one naming route birth cannot serve (herdr labels a new workspace’s root tab1with no birth flag to change it).mux.group(target, group, name?, deps?)— group an already-open tab intogroup, storing the tab’s ownnamebeside it.MuxOpenOptions.workspaceGrouproutes through this.
Driving a pane
Section titled “Driving a pane”mux.sendText(target, text, deps?)— typetextliterally, pressing no Enter. Text that names a key (Enter,Up) is typed as those characters, never interpreted.mux.sendKeys(target, keys, deps?)— press named keys in order (UpDownEnterEscapeTabC-cF1–F12, …). Never adds an Enter you did not write.mux.submit(target, text?, deps?)— take the pane’s turn: typetextif given, then always press Enter. With no text, sends a bare Enter only — flushing an already-staged buffer without re-typing it. Seenudgefor the send-and-verify wrapper.mux.read(target, opts?, deps?)→{ text, truncated? }— capture the pane’s current output.opts.linesis the read WINDOW: a row count,'all'for the whole scrollback, or omitted for the backend’s own default (the viewport). Passopts.truncationto also learn whether rows above that window were dropped;truncatedis absent unless you ask, because absent means undetermined and afalsethat means “I did not check” is indistinguishable from “you have everything”. Every backend answers it: each asks for one row more than the window and compares row counts, which costs one extra query — none on Zellij (whoselinesread already holds the whole scrollback), and none atlines: 'all', where an unbounded window omitted nothing by construction. Opt-in becausereadis the hottest verb on this seam; the CLI, one invocation per process, always asks (read).mux.waitForOutput(target, opts, deps?)→Promise<MuxWaitResult>— block until the pane’s output matches, or the deadline passes.optscarries the pattern (matchliteral orregex),timeoutMs, and the samelineswindowreadtakes; the result says whether it matched and carries the output either way, so a caller that guessed the wrong pattern still sees what the pane said. The one asynchronous method on the seam.mux.focus(target, deps?)— beam the attached client to the pane, across workspace and tab.mux.nudge(target, message, opts?, deps?)—submitwith a receipt; seenudge.
Inspecting and tearing down
Section titled “Inspecting and tearing down”mux.paneExists(target, deps?)→boolean— whether the pane is still live.mux.isPaneFocused(target, deps?)→boolean | undefined— read-only focus probe, three-valued on every backend:truepositively focused,falsepositively not,undefinedthe backend could not answer (callers fail open).undefinedis never a stand-in forfalse— an unresolvable pane, a listing that carries no focus field, and a session with no client attached all report it, because a confident “not focused” read out of a silence is a plain wrong answer rather than a cautious one.mux.listPanes(deps?)→LivePane[]— enumerate every live pane the backend can see.mux.teardown(target, deps?)— close the pane.
Optional capabilities
Section titled “Optional capabilities”Three members are present only on backends that support the underlying concept — check for them
before use. All are reached bound, the same way as the rest of the session (methods take deps?, no
Exec):
mux.worktree?— aBoundWorktreeWorkspaceCapability, present on herdr. On tmux, rmux, WezTerm, Zellij, cmux, and otty it isundefined; fall back to plain git plusmux.open.mux.regions?— geometry introspection (describeRegion/describeWorkspace), present on tmux, rmux, and herdr, absent on WezTerm, Zellij, cmux, and otty. Backstemplate save. The rawRegionInspectorcarries a third member,resizePane(exec, target, ratio), which the bound form does not expose.mux.agentLifecycle?— the native per-pane agent-state wait (waitForState), present on herdr and otty and absent everywhere else. See Agent, which is where the emulate-or-refuse decision lives.
Then the capability flags — static declarations, not methods:
mux.canSizeSplits?— whether the backend honorsratio;false/absent means a requested ratio degrades to the backend’s own even split. The one flag the bound session carries; the four below are read from the raw adapter.adapter.canFloatPanes?— whetherpane:floatopens a real floating pane. tmux 3.7+ and Zellij declare it; everywhere else a float is refused by name rather than substituted with a split.adapter.canZoomPanes?— whether a pane can be zoomed to fill its tab (setPaneZoom/isPaneZoomed).adapter.canMovePanes?— whethermovePanecan move a pane beside another.adapter.canBreakPanes?— whetherbreakPanecan break a pane out to its own tab or workspace.
Ask the flag before opening rather than catching the refusal after: that is the whole reason these are declarations.
-
mux.focusOnOpen→'preserved' | 'restored' | 'stolen'— whatopen()does to the caller’s focus. Unlike the two flags above this one is required, so every adapter answers it.'preserved'(tmux, rmux, herdr) means nothing moves at any instant, on any route.'restored'(WezTerm, Zellij 0.45+, cmux, otty) means a focus move happens and is deterministically undone beforeopen()returns — the caller ends where they started, though a human watching may see a flicker.'stolen'means the move stands; no backend declares it today. It replaced the booleanopensWithoutStealingFocus, which had to call a restore either “no theft” or “theft” and chose the first, so Zellij’s visible round trip declared the same value as tmux’s-d.The declaration covers every route
open()can take, including the focus move an adapter makes to choose a split target: a backend whosenew-panehas no target flag honorsfromby focusing that pane first, and that counts. An adapter declares the weakest value any of its routes earns.
The raw seam
Section titled “The raw seam”Everything above is the ergonomic, Exec-bound MuxSession surface. Underneath it is the pure,
exec-injected MuxAdapter — every method takes its Exec as the first argument instead of one
being bound. Reach for it when threading your own runner through per call rather than binding one,
or when composing at a layer below resolveMux.
import { resolveMuxAdapter, callerPane, nodeExec, withReason, type MuxAdapter, type Exec } from 'cyber-mux'resolveMuxAdapter(env, exec?) → MuxAdapter
Section titled “resolveMuxAdapter(env, exec?) → MuxAdapter”Run the probe and return the matching raw adapter (tmux / rmux /
herdr / wezterm / zellij / cmux / otty). Throws if the process is in no supported multiplexer. exec defaults to
nodeExec; resolveMux calls this internally and binds the result into a MuxSession.
const adapter = resolveMuxAdapter(process.env)adapter.name // 'tmux' | 'rmux' | 'herdr' | 'wezterm' | 'zellij' | 'cmux' | 'otty'callerPane(adapter, env)
Section titled “callerPane(adapter, env)”The free-function form of mux.callerPane(), for the raw adapter — this process’s
own pane as a MuxTarget the adapter can address.
The raw method surface
Section titled “The raw method surface”Every MuxSession method above has a raw counterpart that takes exec first and drops deps:
open(exec, opts), rename(exec, target, tier, name), group(exec, target, group, name?),
sendText(exec, target, text), sendKeys(exec, target, keys), submit(exec, target, text?),
read(exec, target, opts?), waitForOutput(exec, target, opts), focus(exec, target),
teardown(exec, target), paneExists(exec, target), isPaneFocused(exec, target),
listPanes(exec). The optional capabilities are reached the same way, exec-first:
adapter.worktree (see Worktree),
adapter.regions, and adapter.agentLifecycle (see Agent).
Four methods live only here, with no bound counterpart on MuxSession — pane geometry the
session surface deliberately does not carry:
setPaneZoom(exec, target, zoomed)— zoom a pane to fill its tab, or restore it. Absolute, not a toggle: a caller that had to read the current state first could not act on a backend that cannot report one.isPaneZoomed(exec, target)→boolean | undefined— the read side, three-valued likeisPaneFocused.movePane(exec, target, destination, side)→OpenedPane— move a pane beside another ('right' | 'down').breakPane(exec, target, at)→OpenedPane— break a pane out into its own'tab'or'workspace'.
Each is gated by the matching can* flag above; a backend without it refuses by name. The
semantics of every method are identical to its bound counterpart described above — only the calling
convention differs.
callerPane and nudge stay exported as free functions for this raw seam,
in addition to being folded into MuxSession as methods.
The Exec seam
Section titled “The Exec seam”Every raw adapter method takes an Exec — a synchronous command runner returning trimmed stdout or
null on failure. resolveMux binds nodeExec (or a supplied fake) into every MuxSession method
for you; the raw seam is where you’d bind it yourself.
import { nodeExec, withReason, type Exec } from 'cyber-mux'nodeExec— the real runner, overexecFileSync.exec.lastError— the backend’s own words for why the most recent call returnednull, when the runner supplies them. A diagnostic, never a control-flow signal —nullstays the one failure sentinel.withReason(exec, message)— appendexec.lastErrorto a failure message when there is one, so a refused split reports the backend’s actual reason.
A test passes its own Exec that returns canned stdout, driving the whole adapter with no real
multiplexer — or binds it once into a MuxSession with resolveMux(env, { exec: fake }).