Race-condition-safe tmux wrapper built for LLM coding agents — but equally pleasant for humans.
LLM agents need to run shell commands, but raw tmux is fragile: Enter keys get lost, output parsing breaks, errors produce tracebacks instead of structured data. twmux solves this with race-condition-safe I/O, a consistent JSON contract, and agent-safe socket isolation.
For agents: Every command returns {"ok": true, ...} or {"ok": false, "error": "..."}. No tracebacks. No Rich markup in JSON. Predictable exit codes. Self-discoverable via twmux --json.
For humans: Rich-formatted output, helpful error messages, monitor commands for watching agent work. The --json flag is opt-in; without it, everything is human-friendly.
- Structured JSON contract - Consistent
{"ok": true/false, ...}envelope for all commands, errors included - Agent isolation - Default socket
claudekeeps agent operations separate from user tmux - Safety boundaries - Non-agent sockets require
--forceflag - Race-condition-safe send - Verifies commands are received before sending Enter
- Execute and capture - Run commands and get output with exit codes
- Marker-based execution - Reliable output capture using unique markers
- Wait-idle detection - Wait until pane output stabilizes
- Self-discoverable -
twmux --jsonlists all commands;twmux --json statusexposes all targets - Flexible targeting - Pane IDs or session:window.pane syntax
- Pane management - Launch, kill, interrupt, move, and escape
- Cross-session moves - Move panes and windows between sessions
- Zero tracebacks - All errors are caught and formatted, even connection failures
Nothing you couldn't do with bare tmux, but much more reliable for agent use.
By default, twmux operates on the claude socket, keeping agent tmux sessions separate from your personal tmux:
# Agent operations (default socket: claude)
twmux new myapp
twmux send -t %0 "echo hello"
twmux status
# User can monitor without interference
tmux -L claude attach -t myapp # Watch agent work
# Ctrl+b d to detach
# Access user's tmux requires explicit --force
twmux --force -L default status # View user's default socketSocket naming:
claude,claude-*- Agent sockets (no--forceneeded)- All other names - Require
--forceflag
uv pip install -e .twmux [OPTIONS] COMMAND [ARGS]| Option | Description |
|---|---|
--json |
Output as JSON (for programmatic use) |
-L, --socket NAME |
tmux socket name (default: claude) |
--force |
Allow non-agent sockets (required for non-claude* sockets) |
-v, --verbose |
Verbose output |
Send text to a pane with race-condition-safe Enter handling.
twmux send -t %5 "echo hello"
twmux send -t main:0.1 "make test" --delay 0.1
twmux send -t %5 "partial text" --no-enterExecute a command and capture output with exit code.
twmux exec -t %5 "ls -la"
twmux --json exec -t main:0 "make test" --timeout 60Returns:
output: Command stdout/stderrexit_code: Command exit code (-1 if timeout)timed_out: Whether command timed out
twmux capture -t %5
twmux capture -t %5 -n 50 # Last 50 lines
twmux --json capture -t main:0Wait until pane output stops changing.
twmux wait-idle -t %5
twmux wait-idle -t %5 --timeout 10 --interval 0.1twmux interrupt -t %5twmux escape -t %5Split current pane to create a new one.
twmux launch -t %5 # Split below
twmux launch -t %5 -v # Split right (vertical)
twmux launch -t %5 -c "python3" # Split and run commandtwmux kill -t %5Move a pane to another session, creating a new window or joining an existing one.
twmux move-pane -t %5 debug # New window in "debug"
twmux move-pane -t %5 debug:0 # Join window 0 in "debug"
twmux --json move-pane -t %5 debug # JSON outputReturns: {"ok": true, "pane_id": "%5", "destination_session": "debug", "new_window": true}
Move an entire window (with all panes) to another session.
twmux move-window -t build:0 debug # Move window 0 of "build"
twmux move-window -t %5 debug # Move window containing %5
twmux --json move-window -t build:0 debug # JSON outputReturns: {"ok": true, "window_id": "@1", "window_index": "1", "pane_ids": ["%2", "%3"], "destination_session": "debug"}
Create a new tmux session on the agent socket. Prints monitor command for user observation.
twmux new myapp # Create session "myapp"
twmux new myapp -c "python3" # Create and run command
twmux -L claude-isolated new test # Use different agent socketOutput includes monitor command:
Session created: myapp on socket claude
Pane ID: %0
To monitor: tmux -L claude attach -t myapp
To detach: Ctrl+b d
twmux kill-session myappKill the entire tmux server for a socket.
twmux kill-server # Kill default claude server
twmux -L claude-isolated kill-server # Kill specific sockettwmux status # Show default socket (claude)
twmux status --all # Show all agent sockets (claude*)
twmux --force status --all # Show all sockets including user'sThe -t option accepts tmux target syntax to identify panes.
Direct pane reference using tmux pane ID:
twmux send -t %5 "echo hello" # Pane ID %5
twmux exec -t %12 "ls" # Pane ID %12Get pane IDs with twmux status or tmux list-panes -a.
Hierarchical addressing:
# Full path: session:window.pane
twmux send -t main:0.1 "echo hello" # Session "main", window 0, pane 1
twmux send -t dev:2.0 "make test" # Session "dev", window 2, pane 0
# Partial paths
twmux send -t main:0 "echo hello" # Session "main", window 0, active pane
twmux send -t main: "echo hello" # Session "main", active window/pane
twmux send -t :0.1 "echo hello" # First session, window 0, pane 1| Target | Meaning |
|---|---|
%5 |
Pane with ID %5 (absolute) |
main:0.1 |
Session "main", window 0, pane 1 |
main:0 |
Session "main", window 0, active pane |
main: |
Session "main", active window and pane |
:0.1 |
First session, window 0, pane 1 |
:0 |
First session, window 0, active pane |
| (empty) | First session, active window and pane |
# Start a REPL in a new pane and interact with it
twmux launch -t %5 -c "python3"
# Returns: {"ok": true, "pane_id": "%12"}
# Send commands to the new pane
twmux send -t %12 "print('hello')"
twmux wait-idle -t %12
# Capture output
twmux capture -t %12 -n 10
# Execute and get result
twmux --json exec -t %12 "print(1+1)"
# Returns: {"ok": true, "output": "2", "exit_code": 0, "timed_out": false}
# Clean up
twmux kill -t %12All commands support --json for programmatic use. Every response follows a consistent envelope:
Success: {"ok": true, ...command-specific-fields}
Error: {"ok": false, "error": "human-readable message"}
Exit codes: 0 = success, 1 = any error. All JSON goes to stdout.
$ twmux --json exec -t %5 "echo hello"
{"ok": true, "output": "hello", "exit_code": 0, "timed_out": false}
$ twmux --json send -t %5 "test"
{"ok": true, "success": true, "attempts": 1}
$ twmux --json send -t %999 "test"
{"ok": false, "error": "Pane not found: %999"}
$ twmux --json new myapp
{"ok": true, "session": "myapp", "socket": "claude", "pane_id": "%0", "monitor_cmd": "tmux -L claude attach -t myapp"}
$ twmux --json status
{
"ok": true,
"sockets": [
{
"socket": "claude",
"sessions": [
{
"session_id": "$0",
"session_name": "myapp",
"windows": [...]
}
]
}
]
}An agent encountering twmux for the first time can bootstrap itself:
# Discover available commands
$ twmux --json
{"ok": true, "commands": [{"name": "send", "description": "..."}, ...]}
# Discover available targets
$ twmux --json status
{"ok": true, "sockets": [{"sessions": [{"session_name": "myapp", "windows": [{"panes": [{"pane_id": "%0"}]}]}]}]}
# Use discovered target
$ twmux --json send -t %0 "echo hello"
{"ok": true, "success": true, "attempts": 1}import json, subprocess
def twmux(cmd: list[str]) -> dict:
result = subprocess.run(
["twmux", "--json"] + cmd,
capture_output=True, text=True,
)
response = json.loads(result.stdout)
if not response["ok"]:
raise RuntimeError(response["error"])
return response
# One parser for all commands
twmux(["send", "-t", "%0", "make test"])
twmux(["wait-idle", "-t", "%0"])
output = twmux(["capture", "-t", "%0"])["content"]The send command:
- Sends text without Enter
- Waits (configurable delay)
- Captures pane content
- Sends Enter
- Verifies content changed
- Retries if needed
The exec command:
- Generates unique markers
- Wraps command:
echo START; { cmd; } 2>&1; echo END:$? - Polls pane with progressive expansion (100 → 500 → 2000 → all lines)
- Parses output between markers
- Extracts exit code
The wait-idle command:
- Hashes pane content (MD5)
- Polls at configurable interval
- Returns when N consecutive hashes match
- Times out if content keeps changing
make install # Install dependencies
make test # Run tests
make lint # Check code style
make format # Auto-format code
make check # Run lint + testMIT
