Agent-oriented MCP server for tmux — native process detection, smart triggers, zero-lookup chaining.
Prerequisites: tmux installed, Go 1.21+ or a pre-built binary.
# Install via Go
go install github.com/MadAppGang/tmux-mcp@latest
# Or build from source
git clone https://github.com/MadAppGang/tmux-mcp
cd tmux-mcp && go build -o tmux-mcp .Add to your MCP client config (~/.claude/settings.json for Claude Code):
{
"mcpServers": {
"tmux": {
"command": "tmux-mcp",
"args": ["--shell-type", "zsh", "--scope", "agentic"]
}
}
}Create a session and run a command in two calls:
// Returns sessionId, windowId, and paneId — no lookup needed
{"tool": "create-session", "params": {"name": "dev"}}
// → {"sessionId": "$3", "sessionName": "dev", "windowId": "@5", "paneId": "%6"}
// Synchronous: blocks until done, returns output + exit code
{"tool": "execute-command", "params": {"paneId": "%6", "command": "go build ./..."}}
// → {"paneId": "%6", "output": "", "exitCode": 0}AI coding agents (Claude Code, Codex CLI, Gemini CLI, opencode) need terminal control. Existing MCP servers treat tmux as a dumb pipe: send a command, poll for output, repeat.
That model breaks down in four ways:
- An agent starts
npm run devand needs to know when the server is ready — not when it produced any output. - An agent runs a database migration and needs to detect when psql is waiting for a password — not guess from screen text.
- An agent chains five operations and cannot afford a lookup round-trip between each step.
- An agent watches a build in a background pane while doing other work.
This project solves those four problems directly.
| nickgnd/tmux-mcp | ht-mcp | mcp-interactive-terminal | tmux-mcp (this) | |
|---|---|---|---|---|
| Language | TypeScript | Rust | Node.js | Go |
| Tools | 13 | 6 | 7 | 20 |
| Dependencies | Node.js / npx | None | Node.js + node-pty | None (single binary) |
| Binary size | ~node_modules | ~4MB | ~node_modules | ~7MB |
| Async monitoring | No | No | No | Yes (blocking tools with progress notifications) |
| Process input detection | No | No | Heuristic | Native OS (kernel-level) |
| Structured JSON output | Partial | No | No | Yes (all tools) |
| Zero-lookup chaining | No | No | No | Yes (IDs in every response) |
| Split-screen / pane layout | Yes | No | No | Yes |
| Tmux integration | Yes | No | No | Yes |
| execute-command | Polling | N/A | Heuristic | Synchronous (tmux wait-for) |
ht-mcp is fast and dependency-free but works only with headless terminals it creates itself. It cannot attach to existing sessions or manage pane layouts.
mcp-interactive-terminal detects prompt readiness via output-settling heuristics, which fail when prompts vary or output is slow.
nickgnd/tmux-mcp pioneered the concept but has known limitations: #31 (C-c sent as literal text), #28 (no consecutive command support), #36 (capture-pane returns too many lines). execute-command returns a task ID; the agent must call get-command-result in a polling loop.
Layer 2: Agent Workflows
start-and-watch watch-pane run-in-repl
pane-state write-to-display display-message
Layer 1: Tmux Primitives
create-session create-window split-pane
list-sessions list-windows list-panes
execute-command send-keys capture-pane
screenshot-pane resize-pane rename-session
kill-session kill-window kill-pane
Layer 1 gives you full tmux control. Every response includes structured JSON with IDs so you never need a separate lookup call to chain operations.
Layer 2 provides tools designed around agent workflows: starting a process and waiting for readiness, monitoring a pane for errors asynchronously, interacting with a running REPL, writing coaching text to a display pane.
| Tool | Purpose | Key params | Returns |
|---|---|---|---|
list-sessions |
List all tmux sessions | — | [{id, name, windows, attached}] |
list-windows |
List windows in a session | sessionId |
[{id, name, active, panes}] |
list-panes |
List panes with dimensions and current path | windowId |
[{id, title, active, width, height, currentCommand, currentPath}] |
capture-pane |
Read terminal content | paneId, lines, colors |
raw text |
screenshot-pane |
Visual screenshot opened in browser | paneId, theme, output |
file path or HTML |
create-session |
Create a detached session | name (optional) |
{sessionId, sessionName, windowId, paneId} |
create-window |
Add a window to a session | sessionId, name |
{windowId, windowName, paneId} |
split-pane |
Split a pane horizontally or vertically | paneId, direction, size |
{paneId, windowId} |
send-keys |
Send keystrokes or text to a pane | paneId, keys, literal, enter |
{paneId} |
execute-command |
Run a command and wait for completion | paneId, command |
{paneId, output, exitCode} |
resize-pane |
Resize absolutely or relatively | paneId, width+height or direction+amount |
{paneId} |
rename-session |
Rename a session | sessionId, newName |
{sessionId, name} |
kill-session |
Kill a session and all its windows | sessionId |
{killed} |
kill-window |
Kill a window and all its panes | windowId |
{killed} |
kill-pane |
Kill a single pane | paneId |
{killed} |
execute-command wraps your command with tee and tmux wait-for so it blocks synchronously until the command finishes. Output and exit code come back in one response — no polling, no separate result-fetch call.
send-keys separates literal text (literal=true, the default) from tmux key names (literal=false). This fixes the original project's issue where C-c was sent as five literal characters instead of an interrupt signal. To cancel a running process:
{"tool": "send-keys", "params": {"paneId": "%6", "keys": "C-c", "literal": false}}| Tool | Purpose | Key params | Returns |
|---|---|---|---|
start-and-watch |
Start a command and monitor until a readiness pattern matches | paneId, command, pattern, mode, triggers, timeout |
WatchResult |
watch-pane |
Monitor an existing pane until a trigger fires | paneId, mode, triggers, timeout |
WatchResult |
pane-state |
Get OS-level process state | paneId |
{panePid, foregroundPid, foregroundCmd, isAlive, waitingForInput, exitCode} |
run-in-repl |
Send input to a running REPL and wait for the prompt | paneId, input, promptPattern, timeout |
{paneId, output} |
write-to-display |
Write coaching text to a side pane without entering agent context | paneId, text, clear |
{paneId} |
display-message |
Show a transient notification in the tmux status bar | message, duration |
text |
start-and-watch and watch-pane block until a trigger fires or the timeout expires, then return the result directly. Progress notifications are sent while monitoring.
WatchResult structure:
{
"paneId": "%6",
"event": "pattern:Serving HTTP",
"detail": "Ready — matched: Serving HTTP on port 8765",
"elapsed": 2.14,
"output": "Serving HTTP on port 8765 ...",
"paneState": {
"panePid": 12345,
"foregroundPid": 12347,
"foregroundCmd": "python3",
"isAlive": true,
"waitingForInput": false
}
}Triggers control when start-and-watch and watch-pane stop monitoring. Pass them as a comma-separated string in the triggers parameter.
| Mode | Poll interval | Notify after |
|---|---|---|
quick |
500ms | 1s elapsed or 10 new lines |
medium |
1s | 5s elapsed or 40 new lines |
slow |
2s | 30s elapsed or 100 new lines |
line |
200ms | every new line |
bunch |
500ms | every 10 new lines |
screen |
1s | every 40 new lines |
| Trigger | Fires when |
|---|---|
exit |
Foreground process exits |
shell |
Terminal foreground command returns to an interactive shell |
user_input |
OS kernel reports the foreground process is blocked reading from the tty |
error |
New output matches `error: |
bell |
tmux window bell flag is set |
idle:N |
No new output for N seconds |
pattern:REGEX |
A new output line matches the regex |
start-and-watch defaults to exit,error. watch-pane defaults to exit,user_input,error.
Watch a build, stop on error or after 10 seconds of silence:
{
"tool": "watch-pane",
"params": {
"paneId": "%6",
"mode": "medium",
"triggers": "exit,error,idle:10",
"timeout": 120
}
}Start a dev server, stop when it prints a ready message:
{
"tool": "start-and-watch",
"params": {
"paneId": "%6",
"command": "npm run dev",
"pattern": "Local:.*http|ready in|listening on",
"mode": "quick",
"triggers": "exit,error",
"timeout": 60
}
}pane-state and the user_input trigger use OS-level process inspection — not regex pattern matching on screen output.
Linux reads /proc/<pid>/wchan. When a process blocks in n_tty_read, the kernel writes that function name to wchan. The server also checks /proc/<pid>/syscall: syscall number 0 (read) with file descriptor 0x0 (stdin) confirms the process is waiting for terminal input.
macOS uses sysctl kern.proc.pid to fetch kinfo_proc. Two signals are combined: the kernel wait message field (Wmesg == "ttyin") and a structural check — when the terminal foreground process group ID equals the shell's own process group and the shell is in interruptible sleep, no child has seized the terminal.
Both platforms identify the foreground process by scanning the terminal foreground process group (TPGID), not just the pane's shell PID.
Why this matters:
# Regex-based approach guesses from screen text:
"Enter password:" → maybe waiting for input?
"[sudo] password for jack:" → probably?
"Password:" → could be a log line
# Native detection is definitive:
pane-state → {"waitingForInput": true, "foregroundCmd": "sudo"}
No false positives from log messages. No missed prompts from non-standard prompt formats.
pane-state response:
{
"panePid": 8421,
"foregroundPid": 8456,
"foregroundCmd": "sudo",
"isAlive": true,
"waitingForInput": true
}// Create session — one call returns session, window, and pane IDs
{"tool": "create-session", "params": {"name": "webapp"}}
// → {"sessionId": "$4", "windowId": "@6", "paneId": "%8"}
// Split for log monitoring (30% width on the right)
{"tool": "split-pane", "params": {"paneId": "%8", "direction": "horizontal", "size": 30}}
// → {"paneId": "%9", "windowId": "@6"}
// Start server and wait for readiness (async MCP task)
{"tool": "start-and-watch", "params": {
"paneId": "%8",
"command": "npm run dev",
"pattern": "Local:.*http|ready in",
"timeout": 60
}}
// → {"event": "pattern:ready in", "elapsed": 1.8, "output": "ready in 843ms"}
// Watch side pane for errors while agent does other work
{"tool": "watch-pane", "params": {
"paneId": "%9",
"mode": "medium",
"triggers": "error,pattern:UnhandledPromiseRejection",
"timeout": 300
}}// Start psql — execute-command blocks until the shell prompt returns
{"tool": "execute-command", "params": {"paneId": "%5", "command": "psql -U app mydb"}}
// First query — returns output between input and next prompt
{"tool": "run-in-repl", "params": {
"paneId": "%5",
"input": "SELECT count(*) FROM users;",
"promptPattern": "mydb=#",
"timeout": 10
}}
// → {"paneId": "%5", "output": " count \n-------\n 1247"}
// Second query, same session
{"tool": "run-in-repl", "params": {
"paneId": "%5",
"input": "SELECT id, email FROM users LIMIT 5;",
"promptPattern": "mydb=#"
}}{"tool": "execute-command", "params": {"paneId": "%5", "command": "go build ./..."}}
// success → {"paneId": "%5", "output": "", "exitCode": 0}
// failure → {"paneId": "%5", "output": "./main.go:12: syntax error", "exitCode": 1}No polling. No parsing return values from a separate call. The exit code is in the response.
execute-command tees stdout+stderr to a temp file and signals completion via tmux wait-for, so the exit code accurately reflects the original command even through pipelines.
// Split off a narrow display pane
{"tool": "split-pane", "params": {"paneId": "%5", "direction": "horizontal", "size": 25}}
// → {"paneId": "%6", "windowId": "@3"}
// Write text the user sees — returns only paneId, text stays out of model context
{"tool": "write-to-display", "params": {
"paneId": "%6",
"text": "Running database migration — do not interrupt",
"clear": true
}}
// → {"paneId": "%6"}
// Run the migration in the main pane
{"tool": "execute-command", "params": {
"paneId": "%5",
"command": "migrate -path ./migrations -database $DATABASE_URL up"
}}This project started as a port of nickgnd/tmux-mcp (TypeScript, 239 stars) and became a different design.
| Area | nickgnd/tmux-mcp | This project |
|---|---|---|
| execute-command | Returns task ID; agent polls get-command-result |
Synchronous via tmux wait-for; output + exit code in one response |
| Input detection | None; agent regexes screen text | Native OS: /proc/wchan (Linux), sysctl kern.proc.pid (macOS) |
| send-keys vs execute | Overloaded execute-command with rawMode+noEnter flags |
Separate send-keys (text or key names) and execute-command |
| C-c handling | Issue #31: sends literal "C-c" instead of SIGINT | send-keys with literal=false interprets C-c as interrupt |
| Consecutive commands | Issue #28: unreliable | Each call gets a unique UUID wait channel |
| Response IDs | Inconsistent | All tools return structured JSON with IDs; zero lookup calls needed |
| Async monitoring | None | Smart triggers with progress notifications and optional channel push |
| Runtime | Node.js / npx | Single 7MB Go binary |
| Tool count | 13 | 20 (14 primitives + 6 agent tools) |
The polling model requires an agent to call get-command-result in a loop, wasting round trips and complicating timeout handling. tmux wait-for blocks inside the server process instead, so the agent gets the result in a single call.
tmux-mcp [--shell-type bash|zsh|fish] [--scope agentic|primitives|all] [--channel]
--shell-type controls how execute-command captures exit codes from a pipeline:
| Shell | Exit code expression |
|---|---|
bash (default) |
${PIPESTATUS[0]} |
zsh |
${pipestatus[1]} |
fish |
$status captured before the pipe |
Match this to the shell running inside your tmux panes. A mismatch causes execute-command to report exit code 0 for every command.
--scope controls which tool groups are registered:
| Value | Tools registered |
|---|---|
agentic (default) |
6 Layer 2 tools + 8 essential Layer 1 tools |
primitives |
All 16 Layer 1 tools only |
all |
All Layer 1 and Layer 2 tools |
The scope can also be set via the TMUX_MCP_SCOPE environment variable. The flag takes precedence.
--channel enables Claude Code channel mode. See the Channel mode section below.
MCP resources are also exposed:
tmux://sessions— JSON list of all active sessionstmux://pane/{paneId}— current terminal content of a pane
tmux-mcp can act as a Claude Code channel, pushing terminal events into your session proactively — without a pending tool call.
Start with --channel:
claude --channels server:tmux-mcpOr via .mcp.json:
{
"mcpServers": {
"tmux": {
"command": "tmux-mcp",
"args": ["--shell-type", "zsh", "--scope", "agentic", "--channel"]
}
}
}When a trigger fires during watch-pane or start-and-watch, the tool result is returned as usual and a notifications/claude/channel notification is pushed. Claude sees the event even while working on something else.
Channel notifications include:
content: human-readable event summarymeta.paneId: which pane firedmeta.event: trigger type (exit,error,user_input,timeout, etc.)meta.detail: explanationmeta.exitCode/meta.isAlive: process state (for exit events)
Without --channel, behavior is identical to previous versions. No channel capability is declared and no notifications are sent.
Prerequisites: Go 1.21+, tmux 3.2+
go build -o tmux-mcp .
go test -short ./... # skip tests requiring tmux
go test ./... # full suite (tmux must be running)Project structure:
| File | Purpose |
|---|---|
main.go |
MCP server setup, Layer 1 tool and resource registration |
tmux.go |
tmuxClient: tmux CLI wrappers, data types, ExecuteCommand with wait-for |
agent_tools.go |
Layer 2 tool registration: start-and-watch, watch-pane, pane-state, run-in-repl, write-to-display, display-message |
channel.go |
Channel mode: ChannelEmitter, notifications, instructions |
triggers.go |
NotificationMode, Trigger, monitorPane loop, parseTriggers, WatchResult |
process.go |
PaneState struct, GetPaneState (tmux query + OS dispatch) |
process_darwin.go |
macOS: sysctl kern.proc.pid, wmesg ttyin detection |
process_linux.go |
Linux: /proc/wchan, /proc/syscall detection |
process_other.go |
Stub for other platforms (WaitingForInput always false) |
e2e_test.go |
MCP client harness and individual tool tests (50 E2E tests across 3 files) |
scenarios_test.go |
Multi-step scenario tests including channel mode |
missing_tests_test.go |
Bug fix coverage, scope filtering, trigger and negative tests |
--shell-type does not match the shell in the pane.
tmux-mcp --shell-type=zsh # for zsh panes
tmux-mcp --shell-type=fish # for fish panesThe pane ID does not exist, or the readiness pattern never appears in output. Verify the pane ID with list-panes. Test the regex against real output with capture-pane before adding it to start-and-watch.
On Linux, some shells use select() or poll() rather than blocking read(), so /proc/wchan shows ep_poll instead of n_tty_read. The tool falls back to isAlive=true, waitingForInput=false. Use idle:N as a complement when precise input detection is required.
No tmux server is running, or the server has no sessions. list-sessions returns [] rather than an error when tmux reports "no server running". Call create-session to start one.
The binary path in the MCP config is wrong or the binary is not executable.
chmod +x /path/to/tmux-mcp
/path/to/tmux-mcp --help # verify it startsMIT