voiceos-tmux-mcp
by seanchiuai
README.md
# voiceos-tmux-mcp
A VoiceOS integration for steering hand-started Claude Code sessions by voice.
Claude Code sessions started in a terminal register themselves under
`~/.claude/sessions/` and record the tmux pane they are running in. This server reads that
registry, matches each entry against the panes tmux reports as alive, and exposes the result
as nine MCP tools. That makes it possible to ask aloud what is running, what a session said,
what it is waiting on, and to answer it — without leaving whatever you were doing.
It runs as a `remote-mcp` integration whose URL is on this Mac: `http://127.0.0.1:7392/mcp`.
"Remote" is about the transport, not the location — VoiceOS makes an HTTP call to a server
you start here, rather than spawning it. That is forced by the app: installation goes by URL,
not by folder. The listener is bound to `127.0.0.1`, so only processes on this Mac can reach
it. Nothing is sent anywhere, and only local tmux sockets are ever addressed.
The server must be running or the integration is dead — see **Start the server** below.
## Tools
| Tool | What it does |
| --- | --- |
| `smoke_test_tmux` | Reports whether the integration can see tmux at all — the "is this alive" check when something breaks after an OS or app update. |
| `list_claude_sessions` | Lists the running Claude Code sessions with the folder each is working in. |
| `check_claude_session_status` | Reports whether a session is working, idle, or waiting, and what it is waiting on. |
| `read_claude_session` | Reads back a session's recent turns from its transcript, for summarizing aloud. |
| `send_to_claude_session` | Types an instruction into a session and submits it, then confirms the session actually moved. |
| `read_claude_prompt` | Reads the approval prompt a waiting session is showing, with its numbered options and a fingerprint. |
| `answer_claude_prompt` | Answers that prompt by option number, refusing if the prompt changed since it was read out. |
| `launch_claude_session` | Starts a new session in a given folder, optionally with a first instruction. |
| `interrupt_claude_session` | Stops what a session is doing, without closing it. |
## Safety model
**Every write target resolves through the session registry.** No tool takes a pane, socket,
or window argument. A tool is given a session *name*, which is matched against
`~/.claude/sessions/` entries that declare a `tmux` pane, have that pane alive right now,
**and whose recorded pid is in the foreground process group of that pane's terminal**. Panes
with no Claude Code registry entry — a bare shell, an editor, a `tail -f` — are not in the
set the lookup searches, so they are unreachable by construction rather than by a filter that
could be bypassed. This matters because text sent to a bare shell is *executed*.
The foreground-process-group condition is the kernel's own answer to "who receives a
keystroke typed into this pane", and it is what closes three holes that a liveness check
alone leaves open:
- **Two entries claiming one pane.** A Claude Code started from inside another inherits
`$TMUX` and registers the *same* pane under its own pid. Under liveness alone both were
steerable, and sending to the nested one typed into the other session's input box. The
nested process does not own the pane's terminal, so it no longer resolves.
- **A tmux server restart.** Pane ids are monotonic within one server and never recycled,
but a restart resets the counter to `%0` and reallocates the whole id space — and the pty
device numbers come back identical too. A registry entry written before a reboot therefore
names a live, unrelated pane on the same `/dev/ttysNNN`. Its pid is dead, so it is refused.
- **A session that has handed over its terminal** to a pager, an editor or a shell. Text
typed there lands in the pager, not the agent. Such a session is reported as running but
not accepting input, and is never a write target.
It **fails closed** everywhere: an unreadable tty, a failed `ps`, or a pid that cannot be
determined all mean the session does not resolve. A session that silently disappears is an
annoyance; a wrong target is a stray keystroke into somebody's terminal. One race remains and
is not closable from here: the foreground group can change between the check and the
keystroke. The window is milliseconds, not eliminated.
Three further guards:
- **Empty-input check.** `send_to_claude_session` refuses to type when the session already
has text in its input box, rather than merging into a half-written line.
- **Prompt fingerprint.** `read_claude_prompt` hashes the entire prompt block. `answer_claude_prompt`
re-reads the screen at the moment of answering and refuses if the hash differs — so an
approval spoken for one command cannot land on a different one that appeared meanwhile.
- **Explicit sockets.** Every tmux invocation passes `-S <socket>`. The server never relies
on the ambient default socket.
There is deliberately no general "run a terminal command" tool: `ls` and `rm -rf ~` would be
the same call differing only by a model-written string.
## Start the server
```bash
./start.sh # leave it running; Ctrl-C stops it
```
It listens on **`http://127.0.0.1:7392/mcp`**. Port 7392 is fixed in `server-http.mjs`;
7391 belongs to the environment probe on this Mac. Confirm it is up and loopback-only:
```bash
curl http://127.0.0.1:7392/health # {"ok":true,...}
lsof -nP -iTCP:7392 -sTCP:LISTEN # must say 127.0.0.1:7392, never *:7392
```
`start.sh` resolves `node` absolutely (`/opt/homebrew/bin/node`, then `/usr/local/bin/node`,
then `PATH`) and installs dependencies on first run if `node_modules/` is absent, so a
stripped environment fails loudly instead of silently. It `exec`s node, so Ctrl-C reaches
the server rather than a shell wrapper.
### Starting it at login instead
`com.seanchiu.voiceos-tmux-mcp.plist.sample` is a launchd login agent that brings the server
up at login and restarts it if it dies. It is a **sample — not installed and not loaded.**
Install commands are in the comments at the top of the file. Two details there are
load-bearing: the job runs through `zsh -lc` because launchd hands a job a minimal PATH
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the login shell's; and it `exec`s node so `KeepAlive`
watches the server instead of a zsh parent.
## Install into VoiceOS
With the server running, install by URL and give VoiceOS:
```
http://127.0.0.1:7392/mcp
```
The install-by-URL option lives under Settings → Agent Mode → Integrations. (The exact menu
wording is not recorded here — the env-probe integration was installed this way at
`http://127.0.0.1:7391/mcp` on this Mac and answered, but the label was never written down.)
VoiceOS handshakes with the endpoint and discovers the nine tools from the server itself.
`voiceos.integration.json` declares the same URL under `runtime`, and its tool list is what
`verify.mjs` checks the server against.
### The stdio path is still here
`server.mjs` + `run.sh` still work and still speak stdio, for a `local-mcp` runtime:
```json
"runtime": { "kind": "local-mcp", "command": "/bin/zsh", "args": ["run.sh"] }
```
Both entrypoints call `buildServer()` from `lib/tools.mjs`, so the nine tools are defined
once and the two transports cannot drift apart.
## Verify
```bash
node verify.mjs
```
Prints the live sessions the state layer can see, then checks that the tools declared in
`voiceos.integration.json` and those registered in `lib/tools.mjs` are the same set — which
covers both entrypoints, since both build from that one file. Exit code 1 means drift — the
failure that installs cleanly and then exposes nothing. Run it before installing, and after
any change to the tool list.
`verify.mjs` is read-only: it issues only `list-sessions` / `list-panes` and reads two files.
It never sends keys, launches, or interrupts, so it is safe against live sessions.
## Tests
```bash
npm test
```
The glob must stay quoted — `node --test test/` (a bare directory) fails on Node 26.
Tests that need a real tmux server create their own on a scratch socket under `/tmp` and kill
only that one; they never touch the user's socket.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues