Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### What's new

**Ordered raw input with {tooliconl}`send-keys-batch`**

{tooliconl}`send-keys-batch` sends several raw-input operations in order and returns per-operation success or failure metadata. It is intentionally scoped to keystrokes and text input for TUIs or persistent shells; authored command completion stays with {tooliconl}`run-command`, and repeated observation stays with {tooliconl}`capture-since`. (#49, #61)

## libtmux-mcp 0.1.0a12 (2026-06-13)

libtmux-mcp 0.1.0a12 hardens the MCP server's read-only and safety surface and adds a one-call `run_command` tool. Read-only tools can no longer trigger tmux format-job shell evaluation, an invalid safety tier fails closed instead of exposing write tools, and large successful results keep their structured payload. Panes and windows also gain liveness and active-pane metadata, and the package ships a `py.typed` marker. The fastmcp floor rises to 3.4.2 to pick up its explicit `starlette>=1.0.1` floor (CVE-2026-48710).
Expand Down
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Give your AI agent hands inside the terminal — create sessions, run commands,
| **Server** | `list_servers`, `list_sessions`, `create_session`, `kill_server`, `get_server_info` |
| **Session** | `list_windows`, `get_session_info`, `create_window`, `rename_session`, `select_window`, `kill_session` |
| **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` |
| **Pane** | `run_command`, `send_keys`, `paste_text`, `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `find_pane_by_position`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `wait_for_channel`, `signal_channel`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `respawn_pane`, `kill_pane` |
| **Pane** | `run_command`, `send_keys`, `send_keys_batch`, `paste_text`, `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `find_pane_by_position`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `wait_for_channel`, `signal_channel`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `respawn_pane`, `kill_pane` |
| **Options** | `show_option`, `set_option` |
| **Environment** | `show_environment`, `set_environment` |
| **Buffers** | `load_buffer`, `paste_buffer`, `show_buffer`, `delete_buffer` |
Expand Down Expand Up @@ -88,13 +88,26 @@ terminal it is using — pytest finishing, a dev server printing its
port, a deploy log settling. The difference then is not more access
to tmux, but a better place to put the control loop.

The server-side moves are three:
The server-side moves are:

**Running.** [`run_command`](https://libtmux-mcp.git-pull.com/tools/pane/run-command/)
sends an authored shell command, waits for deterministic completion,
and returns exit status plus tail-preserved output as one typed value.
The alternative is teaching every agent to compose `send-keys`,
`wait-for`, and a pane capture correctly.

**Driving.** [`send_keys_batch`](https://libtmux-mcp.git-pull.com/tools/pane/send-keys-batch/)
sends several ordered raw-input operations for TUIs and persistent
shell interaction. It is deliberately not a workflow DSL; command
completion stays in `run_command`, and repeated observation stays in
`capture_since`.

**Waiting.** [`wait_for_text`](https://libtmux-mcp.git-pull.com/tools/pane/wait-for-text/)
and [`wait_for_content_change`](https://libtmux-mcp.git-pull.com/tools/pane/wait-for-content-change/)
block inside the server until the condition fires. The alternative is
the model polling `capture-pane` in a loop, paying both context tokens
and round-trip latency for every turn.
block inside the server until a condition fires for output the agent
does not author. The alternative is the model polling `capture-pane`
in a loop, paying both context tokens and round-trip latency for every
turn.

**Reading.** [`snapshot_pane`](https://libtmux-mcp.git-pull.com/tools/pane/snapshot-pane/)
returns content, cursor, copy-mode state, and scroll offset as one
Expand Down
4 changes: 4 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ def _patched_tool_collector_tool(self: ToolCollector, **kwargs: t.Any) -> t.Any:
"EnvironmentSetResult",
"WaitForTextResult",
"ContentChangeResult",
"RunCommandResult",
"SendKeysOperation",
"SendKeysOperationResult",
"SendKeysBatchResult",
"HookEntry",
"HookListResult",
"BufferRef",
Expand Down
6 changes: 3 additions & 3 deletions docs/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ These are the actual tool headings as they render on tool pages:

### In prose

Use {tooliconl}`search-panes` to find text across all panes. If you know which pane, use {tooliconl}`capture-pane` for one read or {tooliconl}`capture-since` for repeated observation. After running a command with {tooliconl}`send-keys`, compose `tmux wait-for -S` and call {tooliconl}`wait-for-channel` before capturing.
Use {tooliconl}`search-panes` to find text across all panes. If you know which pane, use {tooliconl}`capture-pane` for one read or {tooliconl}`capture-since` for repeated observation. For authored shell commands, use {tooliconl}`run-command` instead of manually sending, waiting, and capturing.

### Dense inline (toolref, no badges)

The fundamental pattern: {toolref}`send-keys` → {toolref}`wait-for-channel` → {toolref}`capture-pane`. For discovery: {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`get-pane-info`.
The fundamental command pattern: {toolref}`run-command` → inspect `exit_status` and `output`. For discovery: {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`get-pane-info`.

## Environment variable references

Expand All @@ -87,7 +87,7 @@ Use {tooliconl}`search-panes` before {tooliconl}`capture-pane` when you don't kn
```

```{warning}
Do not call {toolref}`capture-pane` immediately after {toolref}`send-keys` — there is a race condition. Compose `tmux wait-for -S` into the command and use {toolref}`wait-for-channel` between them.
Do not call {toolref}`capture-pane` immediately after {toolref}`send-keys` — there is a race condition. Use {toolref}`run-command` for authored commands, or {toolref}`capture-since` when input and later observation are intentionally separate.
```

```{note}
Expand Down
50 changes: 25 additions & 25 deletions docs/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ counterpart to the longer narrative recipes in {doc}`/recipes`.
:::{grid-item-card} `run_and_wait`
:link: fastmcp-prompt-run-and-wait
:link-type: ref
Execute a shell command and block until it finishes. Use
{tooliconl}`run-command` when exit status matters.
Execute a shell command through {tooliconl}`run-command` and inspect
its typed result.
:::

:::{grid-item-card} `diagnose_failing_pane`
Expand Down Expand Up @@ -53,47 +53,47 @@ tools instead.
```{fastmcp-prompt} run_and_wait
```

**Use when** the agent needs to execute a single shell command and
wait for completion through an explicit tmux signal.
**Use when** the agent needs to execute a single shell command, wait
for completion, and inspect exit status plus output.

**Why use this instead of `send_keys` + `capture_pane` polling?**
Each rendered call embeds a UUID-scoped ``tmux wait-for`` channel,
so concurrent agents (or parallel prompt calls from one agent) can
never cross-signal each other. The server side blocks until the
channel is signalled — strictly cheaper in agent turns than a
``capture_pane`` retry loop.
{tooliconl}`run-command` sends the command, waits through a private
tmux signal, captures tail-preserved output, and returns exit status
in one typed result. That removes the manual channel plumbing from
the common authored-command workflow.

```{fastmcp-prompt-input} run_and_wait
```

**Sample render** (``command="pytest"``, ``pane_id="%1"``):

````markdown
Run this shell command in tmux pane %1 and block
until it finishes:
Run this shell command in tmux pane %1, wait until it
finishes, and inspect the typed result:

```python
send_keys(
result = run_command(
pane_id='%1',
keys='pytest; tmux wait-for -S libtmux_mcp_wait_<uuid>',
command='pytest',
timeout=60.0,
max_lines=100,
)
wait_for_channel(channel='libtmux_mcp_wait_<uuid>', timeout=60.0)
capture_pane(pane_id='%1', max_lines=100)
```

After the channel signals, read the last ~100 lines to verify the
command's behaviour. Do NOT use a `capture_pane` retry loop —
`wait_for_channel` is strictly cheaper in agent turns.
Use `result.exit_status`, `result.timed_out`, and `result.output`
to decide what happened. Do NOT use a `send_keys` + `capture_pane`
retry loop for authored commands — `run_command` already performs
deterministic completion and returns tail-preserved output.

The payload does not preserve the command's exit status. Use
{tooliconl}`run-command` instead when exit status must be returned as
structured data.
If the task needs persistent shell state or TUI keystrokes instead of
a one-shot shell command, use `send_keys` or `send_keys_batch`, then
observe later output with `capture_since`.
````

Shell ``;`` semantics fire the ``wait-for -S`` whether ``pytest``
succeeded or failed, so the edge-triggered signal never deadlocks the
agent on a crashed command. Status preservation is intentionally
omitted from this prompt recipe.
For custom shell composition that falls outside {tooliconl}`run-command`,
compose ``tmux wait-for -S <channel>`` yourself and call
{tooliconl}`wait-for-channel`. Keep that as the low-level escape hatch,
not the default command-running recipe.

---

Expand Down
19 changes: 12 additions & 7 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,18 @@ Search all my panes for the word "error".

## How it works

When you say "run `make test` and show me the output", the agent executes a three-step pattern:

1. {tool}`send-keys` — send the command (composed with `tmux wait-for -S <channel>`) to a tmux pane
2. {tool}`wait-for-channel` — block deterministically until the command signals completion
3. {tool}`capture-pane` — read the terminal output

This **send → wait → capture** sequence is the fundamental workflow. For commands the agent authors, the channel pattern is deterministic; for output the agent does not author (third-party log lines, daemon prompts, interactive supervisors), substitute {tool}`wait-for-text` for step 2.
When you say "run `make test` and show me the output", the agent follows a typed command pattern:

1. {tool}`run-command` — send the authored shell command, wait for completion, and return exit status plus output
2. Inspect the typed result's `exit_status`, `timed_out`, and `output` fields

This **run → inspect** sequence is the default workflow for commands
the agent authors. For custom shell composition outside
{tool}`run-command`, the lower-level escape hatch is
{tool}`send-keys` with `tmux wait-for -S <channel>` composed into the
payload, followed by {tool}`wait-for-channel`. For output the agent
does not author (third-party log lines, daemon prompts, interactive
supervisors), use {tool}`wait-for-text` or {tool}`wait-for-content-change`.

When you need to keep checking the same pane after that first read, switch to
{tool}`capture-since`: the first call returns a cursor, and follow-up calls
Expand Down
32 changes: 15 additions & 17 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,19 +196,20 @@ create a new pane, then calls {tooliconl}`send-keys` in that pane:

The agent calls {tooliconl}`wait-for-text` on the server pane with
`pattern: "Listening on"` and `timeout: 30`. Once the wait resolves, the
agent calls {tooliconl}`send-keys` in the original pane:
`npm test -- --integration`, then {tooliconl}`wait-for-text` with
`pattern: "passed|failed|error"` and `regex: true`, then
{tooliconl}`capture-pane` to read the test results.
agent calls {tooliconl}`run-command` in the original pane with
`command: "npm test -- --integration"` and a test-appropriate
timeout, then reads `exit_status`, `timed_out`, and `output`.

```{warning}
Calling {toolref}`capture-pane` immediately after {toolref}`send-keys` is a
race condition. {toolref}`send-keys` returns the moment tmux accepts the
keystrokes, not when the command finishes. For commands the agent authors,
compose `tmux wait-for -S <channel>` into the command and call
{toolref}`wait-for-channel` — deterministic, race-free. For output the
agent does not author (server-startup banners, test-result lines like
the ones above), use {toolref}`wait-for-text` instead.
use {toolref}`run-command` — deterministic, typed, and race-free. For
custom shell composition outside that shape, compose
`tmux wait-for -S <channel>` into the command and call
{toolref}`wait-for-channel`. For output the agent does not author
(server-startup banners, daemon prompts), use {toolref}`wait-for-text`
instead.
```

### The non-obvious part
Expand Down Expand Up @@ -393,21 +394,18 @@ long-lived process, I would not hijack it -- I would use a different pane.

### Act

The agent calls {tooliconl}`clear-pane`, then {tooliconl}`send-keys` with
`keys: "pytest; tmux wait-for -S pytest_done"`, then
{tooliconl}`wait-for-channel` with `channel: "pytest_done"`, then
{tooliconl}`capture-pane` to read the fresh output. Composing the
`tmux wait-for -S` signal directly into the shell command is the
deterministic path for authored commands.
The agent calls {tooliconl}`clear-pane`, then {tooliconl}`run-command`
with `command: "pytest"` and a test-appropriate timeout. The result
contains exit status and fresh tail-preserved output without a manual
send-wait-capture sequence.

### The non-obvious part

{toolref}`clear-pane` runs two tmux commands internally (`send-keys -R` then
`clear-history`) with a brief gap between them. Calling
{toolref}`capture-pane` immediately after {toolref}`clear-pane` may catch
partial state. The {toolref}`wait-for-text` call after {toolref}`send-keys`
naturally provides the needed delay, so the sequence clear-send-wait-capture
is safe.
partial state. The {toolref}`run-command` call naturally provides the
needed command-completion boundary, so the sequence clear-run-inspect is safe.

---

Expand Down
13 changes: 10 additions & 3 deletions docs/tools/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ All tools accept an optional `socket_name` parameter for multi-server support. I

**Running a command?**
- {tool}`run-command` — one call to run a shell command, wait for completion, capture output, and return exit status
- {tool}`send-keys` (with `tmux wait-for -S <channel>` composed into the keys) → {tool}`wait-for-channel` → {tool}`capture-pane` — the deterministic path for commands the agent authors
- For output the agent does not author (third-party logs, daemon prompts), use {tool}`wait-for-text` or {tool}`wait-for-content-change` between `send-keys` and `capture-pane`
- {tool}`send-keys` / {tool}`send-keys-batch` — raw interactive input for TUIs, control keys, and persistent shell state
- {tool}`wait-for-channel` — low-level custom completion when `run-command` does not fit the shell composition
- For output the agent does not author (third-party logs, daemon prompts), use {tool}`wait-for-text`, {tool}`wait-for-content-change`, or {tool}`capture-since`
- Pasting multi-line text? → {tool}`paste-text`

**Creating workspace structure?**
Expand Down Expand Up @@ -221,7 +222,13 @@ Split a window into panes.
:::{grid-item-card} send_keys
:link: send-keys
:link-type: ref
Send commands or keystrokes to a pane.
Send raw keystrokes to a pane.
:::

:::{grid-item-card} send_keys_batch
:link: send-keys-batch
:link-type: ref
Send several ordered raw-input operations.
:::

:::{grid-item-card} run_command
Expand Down
10 changes: 5 additions & 5 deletions docs/tools/pane/capture-since.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ without paying to re-read the same scrollback every turn. The first call returns
the current visible screen plus a cursor; later calls pass that cursor back and
receive only rows written or rewritten after it.

**Avoid when** you control the command and only need completion — compose
`tmux wait-for -S <channel>` into the command and call
{tooliconl}`wait-for-channel`. If you need a one-shot content + metadata view,
use {tooliconl}`snapshot-pane`; if you do not know which pane contains text,
use {tooliconl}`search-panes`.
**Avoid when** you control the command and only need completion — use
{tooliconl}`run-command`, which waits and returns exit status plus
output in one typed result. If you need a one-shot content + metadata
view, use {tooliconl}`snapshot-pane`; if you do not know which pane
contains text, use {tooliconl}`search-panes`.

**Side effects:** None. Readonly.

Expand Down
5 changes: 5 additions & 0 deletions docs/tools/pane/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ Evaluate a tmux format string against a target.
Send keystrokes or commands to a pane.
:::

:::{grid-item-card} {tooliconl}`send-keys-batch`
Send several ordered raw-input operations.
:::

:::{grid-item-card} {tooliconl}`run-command`
Run a shell command, wait, and capture output.
:::
Expand Down Expand Up @@ -115,6 +119,7 @@ get-pane-info
find-pane-by-position
display-message
send-keys
send-keys-batch
run-command
paste-text
pipe-pane
Expand Down
3 changes: 2 additions & 1 deletion docs/tools/pane/run-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
result with exit status, timeout state, and captured pane output.

**Avoid when** you need raw interactive key driving — use
{tooliconl}`send-keys` for TUIs, key names, and partial commands.
{tooliconl}`send-keys` or {tooliconl}`send-keys-batch` for TUIs, key
names, and partial commands.

**Side effects:** Sends a command to the pane's interactive shell. The
command may read or write files, start processes, or access the network
Expand Down
63 changes: 63 additions & 0 deletions docs/tools/pane/send-keys-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Send keys batch

```{fastmcp-tool} pane_tools.send_keys_batch
```

**Use when** you need to send several ordered raw-input operations to
one or more panes: TUI keystrokes, partial shell input, or persistent
shell interaction that should remain below the command-completion
layer.

**Avoid when** you need to run shell commands and capture results —
use {tooliconl}`run-command` for authored commands, or combine
{tooliconl}`send-keys` with {tooliconl}`capture-since` when later
observation is intentionally separate from input.

**Side effects:** Sends keystrokes to target panes in order. With
`on_error="stop"` the batch stops at the first failed operation and
returns that failure in the result. With `on_error="continue"` later
operations are still attempted.

**Example:**

```json
{
"tool": "send_keys_batch",
"arguments": {
"operations": [
{"pane_id": "%2", "keys": "C-c", "enter": false},
{"pane_id": "%2", "keys": "npm run dev"}
],
"on_error": "stop"
}
}
```

Response:

```json
{
"results": [
{
"index": 0,
"pane_id": "%2",
"success": true,
"error": null,
"elapsed_seconds": 0.01
},
{
"index": 1,
"pane_id": "%2",
"success": true,
"error": null,
"elapsed_seconds": 0.01
}
],
"succeeded": 2,
"failed": 0,
"stopped_at": null
}
```

```{fastmcp-tool-input} pane_tools.send_keys_batch
```
Loading