tmux-mcp
Agent-oriented MCP server for tmux — native process detection, smart triggers, and one handle: the slot.
Quick start
Prerequisites: tmux installed, Go 1.26.1+ or a pre-built binary.
# Install via Go
go install github.com/MadAppGang/tmux-mcp/v2@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"]
}
}
}
Get a terminal and run something in it — no setup call, no handle to carry:
// A pane beside the agent is created on first use. Synchronous: blocks until
// the command finishes, and returns output plus the exit code.
{"tool": "execute-command", "params": {"command": "go build ./..."}}
// → {"slot": 1, "created": true, "output": "", "exitCode": 0, "timedOut": false}
// The same slot is the same pane next time.
{"tool": "capture-pane", "params": {}}
// → the pane's text, with structuredContent {"slot": 1}
Why this exists
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.
Comparison
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.
Helper slots: the only way to name a pane
A slot is a small number — 1, 2, 3 — that stands for a pane. It is the only handle this server has. No tool accepts a pane, window or session identifier, no response returns one, and a request that sends one is refused rather than quietly redirected.
Omit the argument entirely and the call goes to slot 1: a pane beside the agent, in the window the user is already looking at, opened on first use and reused by every later call.
{"tool": "execute-command", "params": {"command": "npm test"}}
{"tool": "capture-pane", "params": {}}
{"tool": "close-pane", "params": {"slot": "all"}}
Slots are plain numbers you choose. There is no "allocate me a free one" form: a caller that needs more than one pane already knows how many, and picking the numbers itself is what lets it address the same pane again on the next call.
Four properties are worth knowing.
- The same slot is the same pane, every time. A dev server started in slot 2 is still running in slot 2 on the next call. That is what makes a bare number sufficient.
- A slot is never the agent's own pane. Resolution can create a pane, reuse one, or adopt an idle unused shell already open in the window — the pane the server itself runs in is excluded from all three, so a tool call can never type into the conversation.
createdtells you when a slot changed underneath you. Every creating call answers withslotandcreated.created: truemeans the pane is new to that slot, which is how an agent learns that the process it started there earlier is gone — the user closed the pane, and the process died with it.- Reading does not create.
capture-pane,screenshot-pane,pane-stateandwatch-paneerror on a slot that was never opened, rather than splitting the user's window to answer a question about a pane that did not exist. They carry nocreatedfield, because nothing they do can create.
Isolated slots: a pane nobody can see
The six tools that can open a pane also take isolated: true. The pane is then created on
a private tmux server with no window and no client attached — nothing appears beside the
user. Everything after that is identical: the same slot number reaches it, through tools
that never mention the kind again.
// First call declares the kind. It needs a slot, because a pane you cannot see
// cannot be found again by looking at the screen.
{"tool": "start-and-watch", "params": {
"slot": 4, "isolated": true,
"command": "npm run build", "pattern": "compiled successfully"
}}
// Later calls just use the number.
{"tool": "capture-pane", "params": {"slot": 4}}
Omitting isolated means "whichever kind this slot already is" — not "visible". Asking
for the wrong kind on an existing slot is an error, never a silent swap. execute-command
is the one exception to the slot requirement: isolated: true with no slot opens a pane,
runs the command, destroys the pane, and returns output with no slot at all.
Closing
close-pane kills panes the server created and merely interrupts (C-c) and releases
panes it adopted from the user — their shell keeps running and their pane stays open.
Closing a slot that was never opened is not an error; it answers action: "none". It
refuses the pane the server itself is running in even when that pane carries a valid
agent-owned record, which is what a subagent started inside an outer agent's helper pane
looks like: closing it would destroy the session the request arrived through.
Tool reference
Thirteen tools. The six that can open a pane take isolated; the four that read take a
slot that must already exist; list-slots and notify take no pane at all.
Working in a pane
Reading a pane
These four error on a slot that has not been opened. None of them accepts isolated: they
read whichever kind the slot already is.
Slots and the user
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": {"keys": "C-c", "literal": false}}
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:
{
"slot": 2,
"created": true,
"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
}
}
created appears on start-and-watch, which can open a pane, and never on watch-pane,
which cannot.
Smart trigger system
Triggers control when start-and-watch and watch-pane stop monitoring. Pass them as a
comma-separated string in the triggers parameter.
Notification modes
Named triggers
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": {
"slot": 2,
"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": {
"command": "npm run dev",
"pattern": "Local:.*http|ready in|listening on",
"mode": "quick",
"triggers": "exit,error",
"timeout": 60
}
}
Native process detection
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:
{
"slot": 1,
"panePid": 8421,
"foregroundPid": 8456,
"foregroundCmd": "sudo",
"isAlive": true,
"waitingForInput": true
}
Agent scenarios
1. Dev server in one slot, error monitoring in another
// Slot 1 gets the server. The pane is opened beside the agent, in the window
// the user is looking at, and start-and-watch blocks until it is ready.
{"tool": "start-and-watch", "params": {
"command": "npm run dev",
"pattern": "Local:.*http|ready in",
"timeout": 60
}}
// → {"slot": 1, "created": true, "event": "pattern:ready in", "elapsed": 1.8,
// "output": "ready in 843ms"}
// Slot 2 tails the log in a second pane, side by side with the first.
{"tool": "execute-command", "params": {"slot": 2, "command": "touch dev.log"}}
{"tool": "start-and-watch", "params": {
"slot": 2,
"command": "tail -f dev.log",
"pattern": "never-matches-on-purpose",
"triggers": "error,pattern:UnhandledPromiseRejection",
"mode": "medium",
"timeout": 300
}}
// The server is still in slot 1 whenever you come back to it.
{"tool": "capture-pane", "params": {"slot": 1, "lines": 200}}
2. REPL session with multiple queries
// Start psql in slot 2 — execute-command blocks until the shell prompt returns.
{"tool": "execute-command", "params": {"slot": 2, "command": "psql -U app mydb"}}
// First query — returns the output between the input and the next prompt.
{"tool": "run-in-repl", "params": {
"slot": 2,
"input": "SELECT count(*) FROM users;",
"promptPattern": "mydb=#",
"timeout": 10
}}
// → {"slot": 2, "created": false, "output": " count \n-------\n 1247", "exited": false}
// Second query, same REPL, same slot.
{"tool": "run-in-repl", "params": {
"slot": 2,
"input": "SELECT id, email FROM users LIMIT 5;",
"promptPattern": "mydb=#"
}}
If a later call comes back with created: true, the REPL is gone — the user closed that
pane — and the next run-in-repl would be talking to a bare shell.
3. Build with an exit code check, out of sight
// isolated:true with no slot opens a pane, runs the command, and destroys the
// pane inside the call. Nothing appears beside the user, and there is no slot
// afterwards because there is no pane left to address.
{"tool": "execute-command", "params": {
"command": "go build ./...",
"isolated": true
}}
// success → {"output": "", "exitCode": 0, "timedOut": false}
// failure → {"output": "./main.go:12: syntax error", "exitCode": 1, "timedOut": false}
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.
4. Coaching display pane
// Slot 3 becomes a display pane. The text goes on the user's screen and the
// tool returns only the slot, so it never enters the model's context.
{"tool": "write-to-display", "params": {
"slot": 3,
"text": "Running database migration — do not interrupt",
"clear": true
}}
// → {"slot": 3, "created": true}
// Run the migration in slot 1 while the message stays up.
{"tool": "execute-command", "params": {
"command": "migrate -path ./migrations -database $DATABASE_URL up"
}}
// Tell the user it is done, and hand the panes back.
{"tool": "notify", "params": {"message": "Migration complete", "duration": 4}}
{"tool": "close-pane", "params": {"slot": "all"}}
clear wipes the pane's line buffer only on a pane the server created. On a pane adopted
from the user it never does: a half-typed command line belongs to them, so the screen is
redrawn around it and successive writes append.
Comparison with the original TypeScript implementation
This project started as a port of nickgnd/tmux-mcp (TypeScript, 239 stars) and became a different design.
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.
Configuration
tmux-mcp [--shell-type bash|zsh|fish] [--backend tmux] [--channel] [--version]
--shell-type controls how execute-command captures exit codes from a pipeline:
Match this to the shell running inside your tmux panes. A mismatch causes
execute-command to report exit code 0 for every command.
--backend selects the multiplexer. tmux is the only implementation today; the flag
exists because the policy layer talks to a Backend port rather than to tmux directly.
--channel enables Claude Code channel mode. See the Channel mode
section below.
There are no MCP resources and no tool-group switch: the server registers one surface of thirteen tools and nothing else.
Channel mode
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-mcp
Or via .mcp.json:
{
"mcpServers": {
"tmux": {
"command": "tmux-mcp",
"args": ["--shell-type", "zsh", "--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 summary, e.g.slot 2: exit — process exited (code 1)meta.slot: which helper slot fired — the same number every tool takesmeta.event: trigger type (exit,error,user_input,timeout, etc.)meta.detail: explanationmeta.exitCode/meta.isAlive: process state (for exit events)
Without --channel, no channel capability is declared and no notifications are sent.
Development
Prerequisites: Go 1.26.1+, 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:
Troubleshooting
execute-command always returns exit code 0
--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 panes
A tool says "slot N does not exist"
The four reading tools do not open panes. Open the slot first with open-pane, or run
something in it with execute-command or start-and-watch, then read it. list-slots
shows every slot this agent currently has.
A call comes back with created: true when you expected a running process
The pane that was in that slot is gone — usually the user closed it — and the process died
with it. created: true is the only signal you get, so treat it as "restart whatever was
running there".
start-and-watch times out without firing
The readiness pattern never appeared in the output. Check what the pane actually printed
with capture-pane on the same slot, and test the regex against that before putting it
back in start-and-watch.
A request is refused with "… is not accepted; address the pane by slot"
The call carried a pane, window or session identifier. This server has no such argument:
use slot. The refusal is deliberate — an ignored identifier would send the call to slot
1 and report success.
pane-state reports waitingForInput=false when a prompt is visible
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.
MCP client does not see the tools
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 starts
Bundled agent skill: tmux-control
The repo ships a Claude Code project skill at .claude/skills/tmux-control/ that teaches
an agent to drive raw tmux from the shell — a different capability from this MCP
server, not an overlapping one.
Use the MCP tools whenever a slot will do: they are safe beside the user, they monitor,
and they never hand out a raw identifier. Reach for the skill when a task genuinely needs
tmux itself — layouts, windows, sessions, other people's panes, or anything this server
deliberately does not expose. That is also the migration path for anyone who used the
raw-tmux tool group removed in v2.0.0; see CHANGELOG.md.
The skill's guidance is empirically verified on tmux 3.6a and was refined through blind
multi-model review (see .claude/skills/tmux-control/evals/).
License
MIT