Skip to content

MuxAdapter

MuxAdapter is the contract the whole library exists to provide: one set of verbs that means the same thing on tmux, herdr, WezTerm, and Zellij. 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'

Run the probe, pick the matching adapter (tmux / herdr / wezterm / zellij), 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' | 'herdr' | 'wezterm' | 'zellij'

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

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.

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

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 and herdr 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 a pane:right/pane:down placement splits (and, for pane: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. Use mux.callerPane().
  • within — the workspace a tab placement opens inside (a workspace value 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 tier at opens.
  • workspaceGroup — an opaque group id for a backend with no workspace tier to group opened spaces under; routed through group.

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',
})
  • 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 tab 1 with no birth flag to change it).
  • mux.group(target, group, name?, deps?) — group an already-open tab into group, storing the tab’s own name beside it. MuxOpenOptions.workspaceGroup routes through this.
  • mux.sendText(target, text, deps?) — type text literally, 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 (Up Down Enter Escape Tab C-c F1F12, …). Never adds an Enter you did not write.
  • mux.submit(target, text?, deps?) — take the pane’s turn: type text if given, then always press Enter. With no text, sends a bare Enter only — flushing an already-staged buffer without re-typing it. See nudge for the send-and-verify wrapper.
  • mux.read(target, opts?, deps?){ text, truncated? } — capture the pane’s current output. opts.lines is the read WINDOW: a row count, 'all' for the whole scrollback, or omitted for the backend’s own default (the viewport). Pass opts.truncation to also learn whether rows above that window were dropped; truncated is absent unless you ask, because absent means undetermined and a false that 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 (whose lines read already holds the whole scrollback), and none at lines: 'all', where an unbounded window omitted nothing by construction. Opt-in because read is the hottest verb on this seam; the CLI, one invocation per process, always asks (read).
  • mux.focus(target, deps?) — beam the attached client to the pane, across workspace and tab.
  • mux.nudge(target, message, opts?, deps?)submit with a receipt; see nudge.
  • mux.paneExists(target, deps?)boolean — whether the pane is still live.
  • mux.isPaneFocused(target, deps?)boolean | undefined — read-only focus probe; undefined means the backend cannot answer (callers fail open).
  • mux.listPanes(deps?)LivePane[] — enumerate every live pane the backend can see.
  • mux.teardown(target, deps?) — close the pane.

Two members are present only on backends that support the underlying concept — check for them before use. Both are reached bound, the same way as the rest of the session (methods take deps?, no Exec):

  • mux.worktree? — a BoundWorktreeWorkspaceCapability, present on herdr. On tmux, WezTerm, and Zellij it is undefined; fall back to plain git plus mux.open.

  • mux.regions? — geometry introspection (describeRegion / describeWorkspace), present on tmux and herdr, absent on WezTerm and Zellij. Backs template save.

  • mux.canSizeSplits? — whether the backend honors ratio; false/absent means a requested ratio degrades to the backend’s own even split.

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 / herdr / wezterm / zellij). 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' | 'herdr' | 'wezterm' | 'zellij'

The free-function form of mux.callerPane(), for the raw adapter — this process’s own pane as a MuxTarget the adapter can address.

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?), 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) and adapter.regions. 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.

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, over execFileSync.
  • exec.lastError — the backend’s own words for why the most recent call returned null, when the runner supplies them. A diagnostic, never a control-flow signal — null stays the one failure sentinel.
  • withReason(exec, message) — append exec.lastError to 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 }).