tmux-kit
Async tmux session-management primitives for Python. The base package is
stdlib only — dependencies = [], and an AST rail in the test suite
fails the build if any core module imports anything outside the standard
library or tmux_kit itself. No fastapi, no httpx, no server stack.
Configuration is injected, never read: no function here knows a settings
file exists.
Your agent's tmux session shouldn't die because you closed your laptop.
tmux-kit spawns and tracks tmux sessions that survive the process that
started them — as a library, a CLI, and an MCP server, all driven by one
identical set of verbs. That guarantee is unconditional for the cases
you hit daily: your own process exits, your terminal disconnects, your SSH
connection drops. The tmux server daemonizes away from whatever
spawned it, so none of that reaches it. It is conditional for one
harder case — a supervisor restarting the service your process runs under.
On Linux, systemd's
default KillMode SIGKILLs everything still in a restarted unit's cgroup,
and a tmux server spawned from that unit is in it; tmux-kit's answer is to
launch the server in its own transient scope (systemd-run --user --scope), which needs a usable systemd --user session — something most
sandboxed agent containers don't have. Run tmux-kit doctor to see which
case you're in.
Quickstart
Two scripts, two separate processes. The first one creates a session and exits; the second, run afterward in a fresh interpreter, finds it and reads it back:
# quickstart_start.py -- creates the session, then exits
import asyncio
import tmux_kit
asyncio.run(tmux_kit.start("quickstart-demo", "echo hello from tmux-kit; sleep 300"))
# quickstart_read.py -- a FRESH process, run afterward, finds and reads it
import asyncio
import tmux_kit
async def main():
sessions = await tmux_kit.list_sessions()
print("still running:", [s.name for s in sessions])
print("it printed:", await tmux_kit.read("quickstart-demo"))
asyncio.run(main())
Runnable, tested copies of both live in examples/. Importing tmux_kit
wires the defaults for you — a dedicated socket directory and the
env-factory plumbing — so a first-time consumer needs to know nothing
about set_env_factory() or tmux_env() to get this far. The caveat that
matters most: that dedicated socket directory is not your ambient tmux
server. Sessions created this way will not appear in a bare tmux ls, and
list_sessions() returning [] means "nothing on tmux-kit's own socket,"
never "nothing running on this host." Override the location with the
TMUX_KIT_SOCKET_DIR environment variable or an explicit
tmux_kit.configure(socket_dir=...); an advanced consumer that wants full
control keeps calling tmux_kit.proc.set_env_factory() directly, and the
facade backs off entirely when it finds a factory already installed.
Before trusting any of this in a new environment, ask it:
$ tmux-kit doctor
tmux_found: True
tmux_version: tmux 3.4
cgroup_mode: scope-candidate
cgroup_escape_ready: True
socket_dir: /home/you/.local/state/tmux-kit/sockets
socket_dir_writable: True
cgroup_mode: scope-candidate with cgroup_escape_ready: True is the
good case for the conditional guarantee above: a systemd --user session
is present and the transient-scope escape has been probed and works.
Where there is no usable systemd --user session — macOS, and most
containers — cgroup_mode comes back not-applicable and doctor
appends a note ending "does not apply here, and nothing needs to be done
about it." Read that narrowly. It is scoped to exactly one hazard, the
cgroup-adoption kill described below; it is not a statement that your
sessions are safe from everything, and a container runtime that tears down
the whole container takes the tmux server with it either way.
doctor never raises for an environment problem — every check degrades to
a field plus a human-readable note, so you can show its output to a user
(or hand it to an agent) without a try/except. --json returns the same
DoctorReport shape the library does.
The incident
tmux resolves which server to talk to in a fixed order: -S, then -L,
then an inherited $TMUX, then TMUX_TMPDIR. $TMUX outranks
TMUX_TMPDIR, so a command that sets TMUX_TMPDIR and passes no
-L/-S is not isolated at all when it runs inside a tmux pane — which
an agent almost always does. During this library's own development, a
hand-written probe of ours did exactly that, believing the fresh
TMUX_TMPDIR had isolated it. Its own tmux list-sessions printed the
operator's 73 real sessions; a kill-server two lines later destroyed all
of them.
Recovery was partial, and it worked as designed. tmux_kit.presence
records sessions by positive observation only — never a TTL, never a
sweep — so it read the server-identity change as a cold start, refused to
tombstone anything, and froze all 71 recoverable sessions into
pending_restore. A later restore brought 40 back and refused 12 more —
each because the session's real working directory wasn't where the default
command would have started it, naming the true path instead of doing the
wrong thing quietly.
Two things shipped in response (0.2.1). isolated_tmux_server(): an async
context manager giving a unique -L socket, a private TMUX_TMPDIR, a
scrubbed $TMUX, and guaranteed teardown even if the body raises. And an
AST-based CI rail in tests/test_rails.py that fails the build if any
test, example, or script in this repo spawns tmux without a literal
-L/-S in the same call. The rail is proven to fire, not only to pass:
its detector was checked against deliberately violating call sites in each
of the three argv shapes it scans, and it flags all three.
What that still does not cover: the rail reads this repo only. It cannot see a consumer's code, and it cannot see a command an agent hand-writes into a shell — which is precisely what caused this. The MCP deny-by-default fence below is a separate, narrower, later mitigation for a different risk, and would not have prevented this one.
One disclosure, because a skeptic will find it in sixty seconds
anyway. proc.tmux_env() sets TMUX_TMPDIR, and run_tmux() never
passes -L/-S — this library's own production path uses the very
mechanism the story above calls unsafe. It is safe only because the same
function calls env.pop("TMUX", None) (proc.py, marked load-bearing in
its docstring). There is no environment shape in which an inherited
$TMUX can leak through and win, because the child never sees it. That
one line is the entire difference between this library and the script that
destroyed 73 sessions.
from tmux_kit import isolated_tmux_server # or tmux_kit.isolation.isolated_tmux_server
async with isolated_tmux_server() as server:
await server.run("new-session", "-d", "-s", "probe")
out = await server.run("list-sessions")
# kill-server + directory removal happen here, even if the body raised
Three older production incidents shaped the code too, each pinned by its
own test: a diagnostic run-shell that painted curl errors across 53 live
sessions; 52 sessions lost to a presence TTL sweep; 44 SIGKILLed when a
service restart took the tmux server its cgroup had adopted. AGENTS.md
carries the writeups.
One vocabulary, three doors in
The verbs in tmux_kit.api — start, list_sessions, status,
exit_code, read, page, search, wait_for_attention, stop,
kill, rename, doctor — are reused by the CLI and the MCP server,
which are thin argument-marshalling wrappers over those exact functions.
Fix or extend a capability in the facade and the other two surfaces
inherit it. --json on the CLI's structured read commands (list,
status, exit-code, page, search, doctor) emits the same dataclass
shapes an import tmux_kit caller gets back.
Two honest exceptions to "identical names." is_running exists only in
the library (it is a convenience wrapper around status, not a separate
call) — there is no CLI command and no MCP tool for it. And the CLI
shortens three verbs for a terminal: list_sessions → list,
wait_for_attention → wait, exit_code → exit-code. MCP keeps all
three at full length.
Both extras are opt-in and neither adds a dependency to the base package:
pip install 'tmux-kit[cli]' # -> the `tmux-kit` command (Click)
pip install 'tmux-kit[mcp]' # -> the `tmux-kit-mcp` stdio server (MCP SDK)
$ tmux-kit start build --command "sleep 300"
started 'build'
$ tmux-kit list
build running
$ tmux-kit status build
running
Every command's --help is written to be read cold by an agent with no
other context: what it does, when to reach for it, what it returns, what
fails and why, and its exit codes.
MCP server: stop/kill are deny-by-default (0.3.0). An MCP client
is an unsupervised agent by construction, so the two destructive lifecycle
verbs refuse every call with PermissionError unless the operator who
launches the server opts in explicitly, in the environment the process is
launched with — never by a parameter on the tool call itself:
TMUX_KIT_MCP_STOP_ENABLED=true TMUX_KIT_MCP_STOP_ALLOW='demo-*' \
TMUX_KIT_MCP_KILL_ENABLED=true TMUX_KIT_MCP_KILL_ALLOW='demo-*' \
tmux-kit-mcp
stop and kill are independently configurable, so an operator can
permit a wider blast radius for the recoverable verb than the
unrecoverable one. An unset, misspelled, or non-true/1/yes
_ENABLED value refuses every call for that verb regardless of _ALLOW.
Read tmux_kit/mcp_server.py's module docstring before assuming the fence
is total — it gates only these two MCP tools (the CLI and any direct
library call remain exactly as unguarded as before), it is one global
policy per server process rather than per connected client, and its
strength is exactly the operator's glob choice: an allowlist of *
authorizes every session name and protects nothing.
Sharing one tmux server between two apps
The tmux server is a shared singleton, and some of its state is a single global slot. If you point a second consumer at a socket another app already uses, four things bite:
- The
alert-bellhook is one slot, last writer wins, silently. Two apps arming it means one of them stops receiving bells with no error. - Presence cross-talk. A cold start observes every session on the socket, not just yours — so an unscoped observation freezes the other app's sessions into your restore plan.
- Fence overlap. The MCP allowlist fence matches glob patterns against session names, so on a shared socket your globs can authorize the other app's sessions.
- Session-name collisions, which surface as a spawn failure at the worst possible moment.
This is why default_socket_dir() resolves to
$XDG_STATE_HOME/tmux-kit/sockets and deliberately not to tmux's own
ambient default. Point at a shared server only via an explicit
configure(socket_dir=...), made with all four hazards in mind;
tmux_kit/CONSUMERS.md documents each in full.
Extending it
tmux_kit/CONSUMERS.md is the single canonical enumeration of every
low-level module and the functions it exports — the facade above is an
additive convenience layer, and proc, spawn, observe, presence,
bell, names, keys, cgroup, and lifecycle remain fully usable on
their own. It is kept as ONE hand-maintained list rather than two: a
second, drifted copy in this README once documented a function that had
never existed, and a first-time reader who copied it got an ImportError
on their first call.
Improvements flow back as PRs against this repo, never as a copy into a consumer.
Versioning
0.x — no semver promise yet. See CHANGELOG.md for what each release
changed. Pin exactly:
dependencies = ["tmux-kit==0.3.2"]
# Pinned git install, for a managed environment that cannot reach public
# PyPI (see CONSUMERS.md):
# tmux-kit @ git+https://github.com/bkrabach/tmux-kit@v0.3.2
Tests
uv sync --extra dev
uv run pytest
That gives 210 passed, 2 skipped on this tree — the two skips are the CLI
and MCP test modules, which importorskip their extras. Install those to
run all 234:
uv sync --extra dev --extra cli --extra mcp
uv run pytest
Several of those are incident tests, not tests written against a spec —
test_presence.py, test_cgroup_escape.py, test_isolation.py,
test_rails.py, and test_differential_harness.py each carry assertions
that exist because a specific thing happened in production. When one
fails, the fix is essentially never to weaken the assertion. Beyond the
unit suite there is a differential harness (pytest -m differential, 22
tests, replaying fleet-recorded real-tmux data) and a real-tmux
integration suite (pytest -m integration, 15 tests, against an isolated
-L socket). CI (.github/workflows/test.yml) runs the full suite
including both markers unconditionally — a CI runner has no live sessions
to endanger — across Python 3.11/3.12/3.13 on Linux, plus an extras job
and a macOS job.
Extracted from muxplex, which remains its first consumer and the archaeological record for this library's pre-extraction history.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tmux_kit-0.3.2.tar.gz.
File metadata
- Download URL: tmux_kit-0.3.2.tar.gz
- Upload date:
- Size: 130.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e52c7c7f23a3bda30db24a4c65a0bffca22d561747922b3e35e77e6e950bd7a9
|
|
| MD5 |
f57042b6e925b244753a2e69a639a96c
|
|
| BLAKE2b-256 |
b6e2a73927a28a0a4c58d10c5aff5edea384b9dca8a51005b03cd17744a46ad7
|
Provenance
The following attestation bundles were made for tmux_kit-0.3.2.tar.gz:
Publisher:
publish.yml on bkrabach/tmux-kit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tmux_kit-0.3.2.tar.gz -
Subject digest:
e52c7c7f23a3bda30db24a4c65a0bffca22d561747922b3e35e77e6e950bd7a9 - Sigstore transparency entry: 2387894705
- Sigstore integration time:
-
Permalink:
bkrabach/tmux-kit@6af0b0fc6c8150bcef2bb95b7201b499c7a6d69f -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/bkrabach
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6af0b0fc6c8150bcef2bb95b7201b499c7a6d69f -
Trigger Event:
push
-
Statement type:
File details
Details for the file tmux_kit-0.3.2-py3-none-any.whl.
File metadata
- Download URL: tmux_kit-0.3.2-py3-none-any.whl
- Upload date:
- Size: 82.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b6367047e317b1e396696d0b5bf8170852ce59971cd6f2bb8d9f64e0ae4c455c
|
|
| MD5 |
fc042fe60c6df3d924cae67f16d695a0
|
|
| BLAKE2b-256 |
8c1ca739c3fe26638cba875a9fa35f93d0e1ccf83ec9fbae53a216f713d3a678
|
Provenance
The following attestation bundles were made for tmux_kit-0.3.2-py3-none-any.whl:
Publisher:
publish.yml on bkrabach/tmux-kit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tmux_kit-0.3.2-py3-none-any.whl -
Subject digest:
b6367047e317b1e396696d0b5bf8170852ce59971cd6f2bb8d9f64e0ae4c455c - Sigstore transparency entry: 2387894723
- Sigstore integration time:
-
Permalink:
bkrabach/tmux-kit@6af0b0fc6c8150bcef2bb95b7201b499c7a6d69f -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/bkrabach
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6af0b0fc6c8150bcef2bb95b7201b499c7a6d69f -
Trigger Event:
push
-
Statement type: