fastmux’s source

Drive and inspect tmux from Python: live session, window, and pane handles with CLI-style reprs

Setup

Running tmux

Everything fastmux does happens by shelling out to the tmux binary, which is the one true API to a tmux server. The foundation is a small runner that executes one tmux command and either returns its stdout or raises.


source

TmuxError

def TmuxError(
    *args, **kwargs
):

Raised when a tmux command fails or a target cannot be resolved.

_tmux takes the command as separate arguments (no shell involved), passes optional input on stdin (used later for loading paste buffers), and strips the trailing newline tmux prints. Any failure becomes a TmuxError carrying tmux’s own message. tmux -V needs no running server, so it makes a good first check:

ver = _tmux('-V')
ver
'tmux 3.7b'
test_eq(ver.startswith('tmux'), True)
with expect_fail(TmuxError, contains='fastmux-empty'): _tmux('-L','fastmux-empty','ls')

Querying tmux

tmux answers questions through format strings: every list-* and display-message command takes -F with #{variable} placeholders. So fastmux describes each concept once, as a mapping of field name to format variable, and parses the tab-separated answer into a typed dict.

Sessions, windows, and panes all have server-unique ids ($1, @1, %1) which never change or get reused. Names and indexes are ambiguous as -t targets (a session auto-named 5 can resolve as a window index!), so fastmux keys every object on its id and only ever targets ids internally.

A field spec is used twice: _fmt renders it as the -F argument, and _parse types the reply. The pane title comes last since it is the one field that may itself contain tabs. scroll_position is blank except in copy-mode, so scroll_pos types as int-or-None rather than int. Some tmux versions report an unknown -t target as success with every field empty rather than as an error, so _get turns that reply into the TmuxError it should have been.

test_eq(_fmt(dict(id='pane_id', w='pane_width')), '#{pane_id}\t#{pane_width}')
test_eq(_parse(dict(id='pane_id', width='pane_width', active='pane_active'), '%3\t80\t1'),
        dict(id='%3', width=80, active=True))

Sessions

A session is tmux’s top-level unit: a named group of windows with its own lifecycle, which clients attach to. All fastmux objects follow the same pattern: an AttrDict holding the fields queried from tmux, a fetch classmethod that builds one from any target, and refresh to re-query in place. The repr matches what tmux ls prints.


source

new_session

def new_session(
    cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
    name:NoneType=None, # Session name; default: tmux auto-numbers
    width:NoneType=None, # Terminal width in columns
    height:NoneType=None, # Terminal height in rows
    cwd:NoneType=None, # Working directory
    env:NoneType=None, # Extra environment vars as a dict
    remain:bool=False, # Keep dead panes around (inspectable, with exit status) instead of destroying them
):

Start a detached tmux session and return its Session


source

Session

def Session(
    *args, **kwargs
):

A tmux session; fields from _sess_f, keyed on the immutable session id

Sessions are created detached (-d), so making one never steals the terminal you are working in. We’ll create one now running a small script that prints numbered lines then waits. It will be the playground for the rest of this notebook.

printer = [sys.executable,'-u','-c','import time\n'
                                    'for i in range(30): print(f"ln {i}")\n'
                                    'time.sleep(600)']
s = new_session(printer, width=80, height=10)
s
0: 1 windows
assert s.id.startswith('$') and s.n_wins==1
test_eq(s.refresh().id, s.id)

source

Session.attach_command

def attach_command():

Shell command that attaches a terminal to this session


source

Session.rename

def rename(
    name
):

Rename this session, returning it


source

Session.kill

def kill():

Kill this session and everything in it

Destructive operations raise a sys.audit event, so audit-hook based sandboxes can gate them. In-place operations return self for chaining.

test_eq(s.rename('fastmux-demo').name, 'fastmux-demo')
test_eq(s.attach_command, 'tmux attach -t fastmux-demo')

Panes

A pane is one terminal: a pty running a command, with a visible screen and a scrollback transcript. Everything you can read or type happens in a pane. Because a pane knows its geometry and cursor, we can treat its whole transcript (history plus visible screen) as a list of lines, addressed absolutely from line 0.


source

Pane

def Pane(
    *args, **kwargs
):

A tmux pane; fields from _pane_f, keyed on the immutable pane id

p = Pane.fetch(s.id)
test_eq((p.width, p.height), (80, 10))
assert p.id.startswith('%') and p.running and p.exit_code is None
with expect_fail(TmuxError, contains="can't find"): Pane.fetch('%9999')
p.target
'fastmux-demo:1.1'

target is the pane’s address in standard tmux syntax (session:window.pane), so it can be pasted into any tmux -t command.

in_mode and scroll_pos report copy-mode state — the one thing capture and screen cannot show, since tmux always captures the underlying pane, never the copy-mode overlay. A driver that sends a wheel gesture asserts arrival with in_mode, and reads how far up the view sits with scroll_pos (lines above the live position; None outside copy-mode). For queries not worth a curated field, fmt evaluates any tmux format string on the pane.

assert not p.in_mode and p.scroll_pos is None
_tmux('copy-mode','-t',p.id)
assert p.refresh().in_mode and p.scroll_pos == 0
_tmux('send-keys','-t',p.id,'-X','scroll-up')
test_eq(p.refresh().scroll_pos, 1)
_tmux('send-keys','-t',p.id,'-X','cancel')
assert not p.refresh().in_mode
test_eq(p.fmt('#{pane_id}'), p.id)

source

Pane.capture

def capture(
    start:int=0, end:NoneType=None, ansi:bool=False
):

A Capture of transcript lines start:end (absolute; end=None means up to the cursor)


source

Capture

def Capture(
    *args, **kwargs
):

Captured transcript lines plus their absolute range and source pane

tmux numbers capture lines relative to the top of the visible screen (scrollback is negative), so capture converts from absolute transcript lines. The Capture repr is the text itself with a source footer:

time.sleep(0.5)
c = p.capture(5, 8)
c
ln 5
ln 6
ln 7
── fastmux-demo:1.1 %0 · lines 5-8 of 30
test_eq(c.lines, ('ln 5','ln 6','ln 7'))
test_eq((c.start, c.end, c.n), (5, 8, 30))
test_eq(p.capture(29, 99).lines, ('ln 29',))  # ranges are clamped

source

Pane.__repr__

def __repr__():

Return repr(self).


source

Pane.ansi

def ansi():

The visible screen with ANSI escape sequences


source

Pane.text

def text():

The visible screen as plain text

A pane’s repr is its current screen, so displaying one is like glancing at that terminal:

p
ln 21
ln 22
ln 23
ln 24
ln 25
ln 26
ln 27
ln 28
ln 29

source

Pane.display

def display(
    lines:int=80, ansi:bool=False
):

Capture the last lines transcript lines, recording the pane’s last-seen state


source

Pane.__getitem__

def __getitem__(
    i
):

Transcript line i as a str, a Capture for a slice; str keys keep normal dict access


source

Pane.__len__

def __len__():

Return len(self).

Indexing and slicing read the transcript like a list of lines, in the same coordinates tmux itself uses, so p[-200:] is the last 200 lines including scrollback. Since Pane is a dict underneath, string keys still do dict lookup (p['id']), while ints and slices read the transcript.

test_eq(len(p), 30)
test_eq((p[0], p[-1]), ('ln 0', 'ln 29'))
test_eq(p[5:7].lines, ('ln 5','ln 6'))
test_eq(p[-3:].lines, ('ln 27','ln 28','ln 29'))
test_eq(p['id'], p.id)
with expect_fail(IndexError): p[99]

The visible screen

capture and display read the transcript, which ends at the cursor — right for shell work, wrong for full-screen apps, which park the cursor mid-screen and paint below it (completion menus, status rows). screen captures the visible viewport regardless of cursor position, with geometry and cursor in its footer, and the cursor cell overlaid as ▮ in the repr (the text/lines fields stay unmarked). styles=True keeps the SGR text attributes that carry UI meaning — dim ghost text, reversed selections — while stripping color codes, which in a syntax-highlighting app outnumber everything else.


source

Pane.screen

def screen(
    styles:bool=False
):

A Screen of the visible viewport (rows below the cursor included), recording the pane’s last-seen state


source

Screen

def Screen(
    *args, **kwargs
):

The visible screen plus geometry, cursor position, and source pane

tui = new_session([sys.executable,'-u','-c',
       "import time\nprint('\x1b[32mdoc\x1b[0m')\nprint('\x1b[2mmenu\x1b[0m\x1b[F', end='', flush=True)\ntime.sleep(60)"],
       width=30, height=6)
time.sleep(0.5)
tp = Pane.fetch(tui.id)
assert 'menu' not in tp.display().text  # the transcript stops at the cursor: the menu row is below it
tp.screen(styles=True)
sc = tp.screen(styles=True)
test_eq((sc.lines, sc.cx, sc.cy), (('doc', '\x1b[2mmenu\x1b[0m'), 0, 0))  # color stripped, attrs kept, cursor parked up
test_eq(_quiet('\x1b[1;32mdoc\x1b[0m \x1b[38;5;208mx\x1b[39m'), '\x1b[1mdoc\x1b[0m x')
tui.kill()

Sending input

fastmux writes to a pane two ways: send pastes literal text through a tmux buffer (no key-name interpretation, so any characters are safe), and send_keys sends tmux key names like Enter or C-c. Both then poll, waiting up to wait_ms for the pane to differ from its last-seen state and returning the latest Capture, so the common send-then-read round trip is one call.


source

Pane.poll

def poll(
    wait_ms:int=0, interval_ms:int=50, lines:int=80, screen:bool=False, styles:bool=False, until:NoneType=None,
    settle_ms:int=0
):

Wait up to wait_ms for the pane to differ from its last-seen state (returning at once if it already does) – or, with until, for the capture to match that regex instead. With settle_ms, then keep sampling until the pane has stopped changing for that long (running at most settle_ms past the deadline). On timeout the latest capture is returned regardless. Capture is the last lines of transcript, or the viewport with screen=True.

Let’s drive a stdin-echo process end to end, sending text and getting the acknowledgement back in the same call:

echoer = new_session([sys.executable,'-u','-c',
                      "import sys\n"
                      "print('ready')\n"
                      "for l in sys.stdin: print(f'ACK:{l.rstrip()}')"],
                      width=60, height=8, remain=True)
ep = Pane.fetch(echoer.id)
ep.poll(wait_ms=1500, lines=3)
ready
── 1:1.1 %1 · lines 0-1 of 1

source

Pane.wait

def wait(
    timeout_ms:NoneType=None, interval_ms:int=50
):

Wait for this pane’s command to exit, returning its status (None on timeout)


source

Pane.interrupt

def interrupt(
    wait_ms:int=0, interval_ms:int=50, lines:int=80, screen:bool=False, styles:bool=False, until:NoneType=None,
    settle_ms:int=0
):

Send Ctrl-C to this pane, then poll


source

Pane.send_keys

def send_keys(
    *keys, wait_ms:int=0, interval_ms:int=50, lines:int=80, screen:bool=False, styles:bool=False,
    until:NoneType=None, settle_ms:int=0
):

Send tmux key names to this pane, then poll


source

Pane.send

def send(
    chars:str='', wait_ms:int=0, interval_ms:int=50, lines:int=80, screen:bool=False, styles:bool=False,
    until:NoneType=None, settle_ms:int=0
):

Paste chars into this pane literally, then poll

r = ep.send('hello\n', wait_ms=1500, lines=3)
assert 'ACK:hello' in r.text
test_eq(ep.wait(timeout_ms=1), None)  # still running

When the process exits, the pane dies but (with remain-on-exit set) stays inspectable, and wait returns the exit status:

ep.send_keys('C-d')  # EOF ends the loop
test_eq(ep.wait(timeout_ms=3000), 0)
echoer.kill()

poll doesn’t just wait for the pane to change after the call: it waits for the pane to differ from the last state a capture showed you. Each display (and so each send, send_keys, interrupt, and poll, which all return one) records the pane’s last-seen state, and poll returns as soon as the pane differs from it. So output that arrived while you weren’t watching satisfies the next poll immediately, rather than making it wait for yet another change:

late = new_session([sys.executable,'-u','-c','import time\n'
                                          'print("early")\n'
                                          'time.sleep(2)\n'
                                          'print("late")\n'
                                          'time.sleep(600)'], width=60, height=8)
lp = Pane.fetch(late.id)
c = lp.poll(wait_ms=500)
assert 'late' not in c.text
c
time.sleep(2.5)  # "late" arrives while we aren't watching
t0 = time.monotonic(); c = lp.poll(wait_ms=5000); elapsed = time.monotonic()-t0
late.kill()
assert 'late' in c.text
assert elapsed < 1

Two refinements cover the patterns that plain change-detection gets wrong. until= waits for the capture to match a regex rather than merely differ — the way to wait out a program’s startup before sending it input. settle_ms= waits for the pane to stop changing: poll returns at the first observed change, so a capture during a burst of output shows a half-painted frame; settling returns the frame the burst ended on.

staged = new_session([sys.executable,'-u','-c','import time\n'
                     'time.sleep(1)\n'
                     'print("READY")\n'
                     'for i in range(5): print(f"burst {i}"); time.sleep(0.15)\n'
                     'time.sleep(600)'], width=60, height=12)
sp = Pane.fetch(staged.id)
t0 = time.monotonic(); c = sp.poll(wait_ms=5000, until='READY'); dt = time.monotonic()-t0
assert 'READY' in c.text and dt < 3          # returned on the match, not the deadline
c = sp.poll(wait_ms=5000, settle_ms=500)     # the burst is mid-flight now
staged.kill()
assert 'burst 4' in c.text                   # settled past the whole burst, not the first change

Mouse input

Terminal apps that enable SGR mouse reporting receive clicks as escape sequences on stdin, so a synthetic click is just bytes — but composing them by hand means counting screen rows and remembering that a real click is a press and a release (apps see the release; sending only the press masks bugs). click does both halves correctly, and can find its own coordinates: pass a 1-based (col,row), or a string (literal) or compiled regex located on the current screen. wheel sends scroll events (SGR buttons 64/65).


source

Pane.wheel

def wheel(
    n:int=3, at:tuple=(1, 1), **poll_kw
):

Send n SGR wheel events (up for positive n, down for negative), then poll with poll_kw


source

Pane.click

def click(
    target, btn:int=0, **poll_kw
):

Send an SGR mouse click (press+release) to this pane’s application, then poll with poll_kw. target is a 1-based (col,row), or a str (literal) or compiled regex located on the current screen – the click lands on the bottom-most match’s first cell (screens grow downward: the newest occurrence is the live one). Raises ValueError if it is not on screen.

peek = new_session([sys.executable,'-u','-c',
                    "import sys, tty; tty.setcbreak(0)\n"
                    "print('alpha [beta] gamma')\n"
                    "while True: print(repr(sys.stdin.buffer.read1(64)))"],
                   width=60, height=12, remain=True)
pk = Pane.fetch(peek.id)
r = pk.click((5, 2), wait_ms=2000)
assert r"\x1b[<0;5;2M\x1b[<0;5;2m" in r.text        # press and release, at the given cell
r = pk.click('[beta]', wait_ms=2000)                # a literal str: regex chars are no trap
assert r"\x1b[<0;7;1M" in r.text                    # found on row 1, col 7
r = pk.click(re.compile(r'gam+a'), wait_ms=2000)
assert r"\x1b[<0;14;1M" in r.text
r = pk.wheel(2, wait_ms=2000)
assert r.text.count(r"\x1b[<64;1;1M") >= 1 and '65;' not in r.text
with expect_fail(ValueError, contains='not on screen'): pk.click('nope')
peek.kill()

Listing panes


source

Session.pane

def pane():

This session’s active pane


source

Session.panes

def panes():

All panes in this session


source

Panes

def Panes(
    items:NoneType=None, *rest, use_list:bool=False, match:NoneType=None
):

Panes shown as tmux list-panes-style summary lines

A Panes collection reprs as summary lines rather than screens (a dozen full screens would be unreadable). The line format mirrors tmux list-panes, with the pane title appended when it carries information (tmux defaults it to the hostname, which doesn’t):

s.panes
1.1: [80x10] %0 python (active)
test_eq(len(s.panes), 1)
test_eq(s.pane.id, p.id)

Windows

A window groups panes into one screenful, like tabs in a terminal emulator. Its repr is its tmux list-windows-style summary line followed by its panes.


source

Session.new_window

def new_window(
    cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
    name:NoneType=None, # Window name; default: tmux auto-names from the command
    cwd:NoneType=None, # Working directory
    env:NoneType=None, # Extra environment vars as a dict
    remain:bool=False, # Keep dead panes around instead of destroying them
    focus:bool=False, # Make it the session's current window
):

Create a window in this session, returning the new Window


source

Session.windows

def windows():

This session’s windows


source

Windows

def Windows(
    items:NoneType=None, *rest, use_list:bool=False, match:NoneType=None
):

Windows shown with their pane listings


source

Window

def Window(
    *args, **kwargs
):

A tmux window; fields from _win_f, keyed on the immutable window id

Like sessions, windows are created without stealing focus unless you pass focus=True, since a script composing a layout shouldn’t yank the user’s cursor around. (remain is a per-window setting; panes made by splitting inherit it from their window.)

w = s.new_window(printer, name='work')
test_eq((w.name, w.n_panes), ('work', 1))
test_eq(s.refresh().n_wins, 2)
assert not w.active  # focus stayed put
w
2: work (1 panes) @2
  2.1: [80x10] %2 tmux (active)

source

Window.layout

def layout(
    name
):

Apply a tmux layout (even-horizontal, tiled, …), returning this window


source

Window.select

def select():

Make this the session’s current window, returning it


source

Window.rename

def rename(
    name
):

Rename this window, returning it


source

Window.resize

def resize(
    width:NoneType=None, height:NoneType=None
):

Resize this window (and so its panes) to absolute width/height, returning it (tmux >= 2.9)


source

Window.kill

def kill():

Kill this window and its panes

test_eq(w.rename('jobs').name, 'jobs')
assert w.select().active

Window.resize is the resize that works in a detached single-pane session. Pane.resize is clamped by the window geometry there, so it silently no-ops; resizing the window reaches the pane and delivers its SIGWINCH, which is what you want when driving a full-screen app headlessly and testing its resize behavior. Panes also carry their process pid, the handle for inspecting a stuck session’s process from outside.

ow, oh = w.panes[0].width, w.panes[0].height
test_eq(w.resize(70, 20).panes[0].width, 70)
assert w.panes[0].pid > 0
w.resize(ow, oh)

Splitting panes

Splitting divides a pane in two, and is how layouts get built. tmux’s own flags are famously backwards (-h puts panes side by side; -v stacks them), so fastmux speaks in directions instead: where the new pane goes, right/below/left/above, with a one-word verb for each. Like window creation, splits never steal focus unless asked.


source

Pane.asplit

def asplit(
    cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
    size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
    cwd:NoneType=None, # Working directory
    env:NoneType=None, # Extra environment vars as a dict
    focus:bool=False, # Make the new pane active
):

Split this pane, returning the new Pane


source

Pane.lsplit

def lsplit(
    cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
    size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
    cwd:NoneType=None, # Working directory
    env:NoneType=None, # Extra environment vars as a dict
    focus:bool=False, # Make the new pane active
):

Split this pane, returning the new Pane


source

Pane.bsplit

def bsplit(
    cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
    size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
    cwd:NoneType=None, # Working directory
    env:NoneType=None, # Extra environment vars as a dict
    focus:bool=False, # Make the new pane active
):

Split this pane, returning the new Pane


source

Pane.rsplit

def rsplit(
    cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
    size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
    cwd:NoneType=None, # Working directory
    env:NoneType=None, # Extra environment vars as a dict
    focus:bool=False, # Make the new pane active
):

Split this pane, returning the new Pane


source

Pane.split

def split(
    where:str='right', # Where the new pane goes: `right`, `below`, `left`, or `above`
    cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
    size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
    cwd:NoneType=None, # Working directory
    env:NoneType=None, # Extra environment vars as a dict
    focus:bool=False, # Make the new pane active
):

Split this pane, returning the new Pane

right = p.rsplit()
above = p.asplit(size=3)
test_eq(len(Window.fetch(p.window_id).panes), 3)
assert above.height==3 and right.id != p.id
assert Pane.fetch(p.window_id).id == p.id  # focus in this window untouched
Window.fetch(p.window_id)
1: nbs (3 panes) @0
  1.1: [40x3] %4 bash
  1.2: [40x6] %0 python (active)
  1.3: [39x10] %3 bash

source

Pane.select

def select():

Make this the active pane, returning it


source

Pane.zoom

def zoom():

Toggle this pane fullscreen within its window, returning it


source

Pane.resize

def resize(
    width:NoneType=None, height:NoneType=None
):

Resize this pane to absolute width/height, returning it


source

Pane.kill

def kill():

Kill this pane (killing its window/session if it is the last one)

test_eq(above.resize(height=5).height, 5)
assert right.select().active
above.kill(); right.kill()
test_eq(len(Window.fetch(p.window_id).panes), 1)

Searching

Any scope can be grepped: one pane’s transcript, every pane in a window or session, or every pane on the server. Matches display rg-style, and each match’s target is a real tmux target, so you can paste it back into tmux() to get the pane that said it.


source

Session.search

def search(
    pattern, lines:int=2000, regex:bool=False, ignore_case:bool=True
):

Search every pane in this session, rg-style


source

Window.search

def search(
    pattern, lines:int=2000, regex:bool=False, ignore_case:bool=True
):

Search every pane in this window, rg-style


source

Pane.search

def search(
    pattern, lines:int=2000, regex:bool=False, ignore_case:bool=True
):

Search this pane’s recent transcript, rg-style


source

SearchResults

def SearchResults(
    items:NoneType=None, *rest, use_list:bool=False, match:NoneType=None
):

Search matches, one rg-style row per line


source

SearchMatch

def SearchMatch(
    *args, **kwargs
):

One matching transcript line, shown rg-style

hits = p.search('ln 2', lines=100)
test_eq(hits[0].line, 'ln 2')
test_eq(len(hits), 11)  # ln 2 and ln 20..29
hits
fastmux-demo:1.1:2: ln 2
fastmux-demo:1.1:20: ln 20
fastmux-demo:1.1:21: ln 21
fastmux-demo:1.1:22: ln 22
fastmux-demo:1.1:23: ln 23
fastmux-demo:1.1:24: ln 24
fastmux-demo:1.1:25: ln 25
fastmux-demo:1.1:26: ln 26
fastmux-demo:1.1:27: ln 27
fastmux-demo:1.1:28: ln 28
fastmux-demo:1.1:29: ln 29
m = hits[0]
test_eq(m.target, p.target)
test_eq(p[m.line_no], m.line)  # line numbers are absolute transcript coordinates

tmux()

tmux() with no arguments returns every session as an indented session/window/pane tree, empty if no server is running at all. With a target it returns the right live handle: a session name or $id gives a Session, sess:win or @id a Window, sess:win.pane or %id a Pane.


source

tmux

def tmux(
    target:NoneType=None
):

All sessions as a tree, or a live handle for target (session name, sess:win, sess:win.pane, or a $/@/% id)


source

Sessions

def Sessions(
    items:NoneType=None, *rest, use_list:bool=False, match:NoneType=None
):

Sessions shown as an indented session/window/pane tree

tmux()
fastmux-demo: 2 windows
  1: nbs (1 panes) @0
    1.1: [80x10] %0 python (active)
  2: jobs* (1 panes) @2
    2.1: [80x10] %2 python (active)
test_eq(tmux(s.name).id, s.id)
test_eq(tmux(s.id).name, s.name)
test_eq(tmux(p.id).target, p.target)
test_eq(tmux(p.target).id, p.id)          # target strings round-trip
test_eq(tmux(p.window_id).idx, p.win)
assert any(o.id==s.id for o in tmux())
with expect_fail(TmuxError): tmux('no-such-session')

Search for a line, then jump to the pane that printed it:

hit = tmux().search('ln 29')[0]
test_eq(tmux(hit.target).id, hit.id)

One more query: current_pane answers “which pane is this?”. Inside tmux it is the caller’s own pane; outside, tmux resolves it to the active pane of the most recently used session. fastmux.bg uses it to give sid=None its meaning.


source

current_pane

def current_pane():

The pane tmux considers current: the caller’s own inside tmux, else the active pane of the most recent session

assert current_pane().id.startswith('%')

Finally, clean up the playground:

s.kill()
with expect_fail(TmuxError, contains="can't find"): tmux('fastmux-demo')