diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py index aca3053..f6d0811 100644 --- a/docs/_ext/fastmcp_autodoc.py +++ b/docs/_ext/fastmcp_autodoc.py @@ -421,7 +421,7 @@ def _extract_enum_values(type_str: str) -> list[str]: return values -class _safety_badge_node(nodes.General, nodes.Inline, nodes.Element): +class _safety_badge_node(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] """Custom node for safety badges with ARIA attributes in HTML output.""" @@ -868,8 +868,12 @@ def _add_section_badges( title_node += _safety_badge(tier) -class _tool_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): - """Placeholder node for ``{tool}`` role, resolved at doctree-resolved.""" +class _tool_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Placeholder node for ``{tool}`` and ``{toolref}`` roles. + + Resolved at ``doctree-resolved`` by ``_resolve_tool_refs``. + The ``show_badge`` attribute controls whether the safety badge is appended. + """ def _resolve_tool_refs( @@ -877,7 +881,10 @@ def _resolve_tool_refs( doctree: nodes.document, fromdocname: str, ) -> None: - """Resolve ``{tool}`` placeholders into links with safety badges. + """Resolve ``{tool}`` and ``{toolref}`` placeholders into links. + + ``{tool}`` renders as ``code`` + safety badge. + ``{toolref}`` renders as ``code`` only (no badge). Runs at ``doctree-resolved`` — after all labels are registered and standard ``{ref}`` resolution is done. @@ -888,6 +895,7 @@ def _resolve_tool_refs( for node in list(doctree.findall(_tool_ref_placeholder)): target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) label_info = domain.labels.get(target) if label_info is None: node.replace_self(nodes.literal("", target.replace("-", "_"))) @@ -908,10 +916,11 @@ def _resolve_tool_refs( newnode += nodes.literal("", tool_name) - tool_info = tool_data.get(tool_name) - if tool_info: - newnode += nodes.Text(" ") - newnode += _safety_badge(tool_info.safety) + if show_badge: + tool_info = tool_data.get(tool_name) + if tool_info: + newnode += nodes.Text(" ") + newnode += _safety_badge(tool_info.safety) node.replace_self(newnode) @@ -930,7 +939,26 @@ def _tool_role( Creates a placeholder node resolved later by ``_resolve_tool_refs``. """ target = text.strip().replace("_", "-") - node = _tool_ref_placeholder(rawtext, reftarget=target) + node = _tool_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _toolref_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:toolref:`capture-pane``` → code-linked tool name, no badge. + + Like ``{tool}`` but without the safety badge. Use in dense contexts + (tables, inline prose) where badges would be too heavy. + """ + target = text.strip().replace("_", "-") + node = _tool_ref_placeholder(rawtext, reftarget=target, show_badge=False) return [node], [] @@ -958,6 +986,7 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.connect("doctree-resolved", _add_section_badges) app.connect("doctree-resolved", _resolve_tool_refs) app.add_role("tool", _tool_role) + app.add_role("toolref", _toolref_role) app.add_role("badge", _badge_role) app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 66b9708..874e40d 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -258,6 +258,7 @@ h4:has(> .sd-badge) { .sd-badge { display: inline-flex !important; align-items: center; + vertical-align: middle; gap: 0.28rem; font-size: 0.67rem; font-weight: 650; @@ -332,6 +333,11 @@ code.docutils + .sd-badge, /* ── Link behavior: underline code only, on hover ───────── */ a.reference .sd-badge { text-decoration: none; + vertical-align: middle; +} + +a.reference:has(.sd-badge) code { + vertical-align: middle; } a.reference:has(.sd-badge) { diff --git a/docs/conf.py b/docs/conf.py index 2b284c5..94df75e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -95,7 +95,7 @@ }, ], "source_repository": f"{about['__repository__']}/", - "source_branch": "master", + "source_branch": "main", "source_directory": "docs/", "announcement": "Pre-alpha. APIs may change. Feedback welcome.", } @@ -140,7 +140,7 @@ # sphinxext-rediraffe rediraffe_redirects = "redirects.txt" -rediraffe_branch = "master~1" +rediraffe_branch = "main~1" # sphinxext.opengraph ogp_site_url = about["__url__"] @@ -259,7 +259,7 @@ def linkcode_resolve(domain: str, info: dict[str, str]) -> None | str: fn = relpath(fn, start=pathlib.Path(libtmux_mcp.__file__).parent) if "dev" in about["__version__"]: - return "{}/blob/master/{}/{}/{}{}".format( + return "{}/blob/main/{}/{}/{}{}".format( about["__repository__"], "src", about["__package_name__"], diff --git a/docs/demo.md b/docs/demo.md new file mode 100644 index 0000000..3b43dfc --- /dev/null +++ b/docs/demo.md @@ -0,0 +1,100 @@ +--- +orphan: true +--- + +# Badge & Role Demo + +A showcase of the custom Sphinx roles and visual elements available in libtmux-mcp documentation. + +## Safety badges + +Standalone badges via `{badge}`: + +- {badge}`readonly` — green, read-only operations +- {badge}`mutating` — amber, state-changing operations +- {badge}`destructive` — red, irreversible operations + +## Tool references + +### `{tool}` — code-linked with badge + +{tool}`capture-pane` · {tool}`send-keys` · {tool}`search-panes` · {tool}`wait-for-text` · {tool}`kill-pane` · {tool}`create-session` · {tool}`split-window` + +### `{toolref}` — code-linked, no badge + +{toolref}`capture-pane` · {toolref}`send-keys` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`kill-pane` · {toolref}`create-session` · {toolref}`split-window` + +### `{ref}` — plain text link + +{ref}`capture-pane` · {ref}`send-keys` · {ref}`search-panes` · {ref}`wait-for-text` · {ref}`kill-pane` · {ref}`create-session` · {ref}`split-window` + +## Badges in context + +### In a heading + +These are the actual tool headings as they render on tool pages: + +> `capture_pane` {badge}`readonly` + +> `split_window` {badge}`mutating` + +> `kill_session` {badge}`destructive` + +### In a table + +| Tool | Tier | Description | +|------|------|-------------| +| {toolref}`list-sessions` | {badge}`readonly` | List all sessions | +| {toolref}`send-keys` | {badge}`mutating` | Send commands to a pane | +| {toolref}`kill-pane` | {badge}`destructive` | Destroy a pane | + +### In prose + +Use {tool}`search-panes` to find text across all panes. If you know which pane, use {tool}`capture-pane` instead. After running a command with {tool}`send-keys`, always {tool}`wait-for-text` before capturing. + +### Dense inline (toolref, no badges) + +The fundamental pattern: {toolref}`send-keys` → {toolref}`wait-for-text` → {toolref}`capture-pane`. For discovery: {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`get-pane-info`. + +## Environment variable references + +{envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_SAFETY` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN` + +## Glossary terms + +{term}`SIGINT` · {term}`SIGQUIT` · {term}`MCP` · {term}`Safety tier` · {term}`Pane` · {term}`Session` + +## Admonitions + +```{tip} +Use {tool}`search-panes` before {tool}`capture-pane` when you don't know which pane has the output you need. +``` + +```{warning} +Do not call {toolref}`capture-pane` immediately after {toolref}`send-keys` — there is a race condition. Use {toolref}`wait-for-text` between them. +``` + +```{note} +All tools accept an optional `socket_name` parameter for multi-server support. +``` + +## Badge anatomy + +Each badge renders as: + +```html + + 🔍 readonly + +``` + +Features: +- **Emoji icon** — 🔍 readonly, ✏️ mutating, 💣 destructive (native system emoji, no filters) +- **Matte colors** — forest green, smoky amber, matte crimson with 1px border +- **Accessible** — `role="note"` + `aria-label` for screen readers +- **Non-selectable** — `user-select: none` so copying tool names skips badge text +- **Context-aware sizing** — slightly larger in headings, smaller inline +- **Sidebar compression** — badges collapse to colored dots in the right-side TOC +- **Heading flex** — `h2/h3/h4:has(.sd-badge)` centers badge against cap-height diff --git a/docs/glossary.md b/docs/glossary.md index 11dea8d..df7c39c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,4 +33,10 @@ Safety tier Socket The Unix socket used to communicate with a tmux server. Can be specified by name (`-L`) or path (`-S`). + +SIGINT + Interrupt signal (Ctrl-C). Sent via {toolref}`send-keys` with `keys: "C-c"` and `enter: false`. Most processes terminate gracefully on SIGINT. + +SIGQUIT + Quit signal (Ctrl-\\). Sent via {toolref}`send-keys` with `keys: "C-\\"` and `enter: false`. Stronger than {term}`SIGINT` — may produce a core dump on Unix. Use as an escalation when SIGINT is ignored. ``` diff --git a/docs/index.md b/docs/index.md index 497b1b3..1bc8ff4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -93,6 +93,7 @@ clients :caption: Use it tools/index +recipes configuration ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 44a162b..5a76ace 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -30,6 +30,16 @@ Here are a few things to try: > 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. {ref}`send-keys` — send the command to a tmux pane +2. {ref}`wait-for-text` — wait for the shell prompt to return (command finished) +3. {ref}`capture-pane` — read the terminal output + +This **send → wait → capture** sequence is the fundamental workflow. Most agent interactions with tmux follow this pattern or a variation of it. + ## Next steps - {ref}`concepts` — Understand the tmux hierarchy and how tools target panes diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..c40ad61 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,328 @@ +(recipes)= + +# Recipes + +Each recipe starts from a real workspace situation and traces the agent's +reasoning through discovery, decision, and action. The goal is not to show +tool-call sequences -- it is to show *how an agent decides* what to do with +existing tmux state so you can write better prompts and system instructions. + +Every recipe uses the same structure: + +- **Situation** -- the developer's world before the agent acts +- **Discover** -- what the agent inspects and why +- **Decide** -- the judgment call that changes the plan +- **Act** -- the minimum safe action sequence +- **The non-obvious part** -- the lesson you would miss from reading tool docs + alone +- **Prompt** -- a natural-language sentence that triggers this recipe + +--- + +## Find a running dev server and test against it + +**Situation.** A developer manages a React project with +[tmuxp](https://tmuxp.git-pull.com). One pane is already running +`pnpm start` with Vite somewhere in the `react` window. They want to run +Playwright e2e tests. The agent does not know which pane has the server, +or what port it chose. + +### Discover + +> {toolref}`list-panes` will not help here -- it shows metadata like current +> command and working directory, not terminal content. The dev server printed +> its URL to the terminal minutes ago, so I need to search terminal content. + +The agent calls {tool}`search-panes` with `pattern: "Local:"` and +`session_name: "myapp"`. The response comes back with pane `%5` in the `react` +window, matched line: `Local: http://localhost:5173/`. + +### Decide + +> The server is alive and its URL is known. I do not need to start anything. +> I just need an idle pane for running tests. + +The agent calls {tool}`list-panes` on the `myapp` session. Several panes show +`pane_current_command: zsh` -- idle shells. It picks `%4` in the same window. + +### Act + +The agent calls {tool}`send-keys` in pane `%4`: +`PLAYWRIGHT_BASE_URL=http://localhost:5173 pnpm exec playwright test` + +Then it calls {tool}`wait-for-text` on pane `%4` with `pattern: "passed|failed|timed out"`, `regex: true`, and `timeout: 120`. Once the +wait resolves, it calls {tool}`capture-pane` on `%4` with `start: -80` to +read the test results. + +```{tip} +The agent's first instinct might be to *start* a Vite server. But +{tool}`search-panes` reveals one is already running. This avoids a port +conflict, a wasted pane, and the most common agent mistake: treating tmux +like a blank shell. +``` + +### The non-obvious part + +{toolref}`search-panes` searches terminal *content* -- what you would see on +screen. {toolref}`list-panes` searches *metadata* like current command and +working directory. If the agent had used {toolref}`list-panes` to find a pane +running `node`, it would know a process exists but not whether it is ready or +what URL it chose. + +**Prompt:** "Run the Playwright tests against my dev server in the myapp +session." + +--- + +## Start a service and wait for it before running dependent work + +**Situation.** The developer is starting fresh in their `backend` session -- +no server running yet. They want to run integration tests, but the test +suite needs a live API server. + +### Discover + +> First I need to know what exists in the `backend` session. If a server is +> already running, I should reuse it instead of starting a duplicate. + +The agent calls {tool}`list-panes` for the `backend` session. No pane is +running a server process. A {tool}`search-panes` call for `"listening"` +returns no matches. + +### Decide + +> Nothing to reuse. I need a dedicated pane for the server so its output +> stays separate from the test output. + +### Act + +The agent calls {tool}`split-window` with `session_name: "backend"` to +create a new pane, then calls {tool}`send-keys` in that pane: +`npm run serve`. + +The agent calls {tool}`wait-for-text` on the server pane with +`pattern: "Listening on"` and `timeout: 30`. Once the wait resolves, the +agent calls {tool}`send-keys` in the original pane: +`npm test -- --integration`, then {tool}`wait-for-text` with +`pattern: "passed|failed|error"` and `regex: true`, then +{tool}`capture-pane` to read the test results. + +```{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. Always use {toolref}`wait-for-text` +between them. +``` + +### The non-obvious part + +{toolref}`wait-for-text` replaces `sleep`. The server might start in 2 +seconds or 20 -- the agent adapts. The anti-pattern is polling with repeated +{toolref}`capture-pane` calls or hardcoding a sleep duration. The MCP server +handles the polling internally with configurable `timeout` (default 8s) and +`interval` (default 50ms). + +**Prompt:** "Start the API server in my backend session and run the +integration tests once it's ready." + +--- + +## Find the failing pane without opening random terminals + +**Situation.** The developer kicked off multiple jobs across panes in a `ci` +session -- linting, unit tests, integration tests, type checking. One of +them failed, but they stepped away and do not remember which pane. + +### Discover + +> I should not capture every pane and read them all -- that is expensive and +> slow. Instead I will search for common failure indicators across all panes +> at once. + +The agent calls {tool}`search-panes` with +`pattern: "FAIL|ERROR|error:|Traceback"`, `regex: true`, scoped to +`session_name: "ci"`. + +### Decide + +> Two panes matched: `%3` has `FAIL: test_upload` and `%6` has +> `error: Type 'string' is not assignable`. I will capture context from each. + +### Act + +The agent calls {tool}`capture-pane` on `%3` with `start: -60`, then on +`%6` with `start: -60`. + +```{tip} +If the error scrolled off the visible screen, use `content_start: -200` (or +deeper) when calling {tool}`search-panes`. The `content_start` parameter +makes search reach into scrollback history, not just the visible screen. +``` + +### The non-obvious part + +{toolref}`search-panes` checks all panes in a single call -- searching 20 +panes costs roughly the same as searching 2. An agent that instead calls +{toolref}`list-panes` then {toolref}`capture-pane` on each one individually +makes 20+ round trips for the same information. The `regex: true` parameter +is required here because the `|` in the pattern is a regex alternation, not +literal text. + +**Prompt:** "Check my ci session -- which jobs failed?" + +--- + +## Interrupt a stuck process and recover the pane + +**Situation.** A long-running build is hanging. The developer wants to +interrupt it, verify the pane is responsive, and re-run the command. + +### Discover + +> I need to send Ctrl-C. This is a tmux key name, not text -- so I must use +> `enter: false` or tmux will send Ctrl-C followed by Enter, which could +> confirm a prompt I did not intend to answer. + +The agent calls {tool}`send-keys` with `keys: "C-c"` and `enter: false` on +the target pane. + +### Decide + +> Did the interrupt work? Some processes ignore {term}`SIGINT`. I will wait briefly +> for a shell prompt to reappear. Developers use custom prompts, so I cannot +> just look for `$`. + +The agent calls {tool}`wait-for-text` with `pattern: "[$#>%] *$"`, +`regex: true`, and `timeout: 5`. + +> If the wait resolves, the shell is back. If it times out, the process +> ignored Ctrl-C. I will escalate: try {term}`SIGQUIT` (`C-\` with `enter: false`), +> then destroy and replace the pane only as a last resort. + +### Act + +If the wait times out, the agent sends `C-\` (also with `enter: false`). If +that also fails, it calls {tool}`kill-pane` on the stuck pane, then +{tool}`split-window` to create a replacement, then {tool}`send-keys` to +re-run. + +```{warning} +The `enter: false` parameter is critical. Without it, {toolref}`send-keys` +sends Ctrl-C *then* Enter, which could confirm a "really quit?" prompt, +submit a partially typed command, or enter a newline into a REPL. +``` + +### The non-obvious part + +Recovery is a two-step decision. Try {term}`SIGINT` first (Ctrl-C), +verify it worked with {toolref}`wait-for-text`, escalate to {term}`SIGQUIT` +only if needed. The escalation ladder is: interrupt, verify, escalate signal, +destroy. Skipping +straight to {toolref}`kill-pane` loses the pane's scrollback history and any +partially written output that might explain *why* it hung. + +**Prompt:** "The build in pane %2 is stuck. Kill it and restart." + +--- + +## Re-run a command without mixing old and new output + +**Situation.** The developer wants `pytest` re-run in tmux, but the +candidate pane already has old test output in scrollback. They want only +fresh results. + +### Discover + +The agent calls {tool}`list-panes` to find the pane by title, cwd, or +current command. If more than one pane is plausible, it uses +{tool}`capture-pane` with a small range to confirm the target. + +### Decide + +> The pane is a shell. I should clear it before running so the capture +> afterwards contains only fresh output. If it were running a watcher or +> long-lived process, I would not hijack it -- I would use a different pane. + +### Act + +The agent calls {tool}`clear-pane`, then {tool}`send-keys` with +`keys: "pytest"`, then {tool}`wait-for-text` with +`pattern: "passed|failed|error"` and `regex: true`, then +{tool}`capture-pane` to read the fresh output. + +### 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. + +**Prompt:** "Run `pytest` in the test pane and show me only the fresh +output." + +--- + +## Build a workspace the agent can revisit later + +**Situation.** The developer wants a durable project workspace -- not just a +quick split, but a layout that later prompts can refer to by role ("the +server pane", "the test pane"). + +### Discover + +> Before creating anything, I need to check whether a session with this name +> already exists. Creating a duplicate will fail. + +The agent calls {tool}`list-sessions`. No session named `myproject` exists. + +### Decide + +> Safe to create. I need three panes: editor, server, tests. I will create +> the session, split twice, then apply a layout so tmux handles the geometry +> instead of me calculating sizes. + +### Act + +The agent calls {tool}`create-session` with `session_name: "myproject"` and +`start_directory: "/home/dev/myproject"`. Then {tool}`split-window` twice +(with `direction: "right"` and `direction: "below"`), followed by +{tool}`select-layout` with `layout: "main-vertical"`. + +The agent calls {tool}`set-pane-title` on each pane: `editor`, `server`, +`tests`. + +The agent calls {tool}`send-keys` in the server pane: `npm run dev`, then +{tool}`wait-for-text` for `pattern: "ready|listening|Local:"` with +`regex: true` and `timeout: 30`. + +```{tip} +If the session *does* already exist, the right move is to reuse and extend +it, not recreate it. The {toolref}`list-sessions` check at the top is what +makes that decision possible. +``` + +### The non-obvious part + +Titles and naming are not cosmetic. They reduce future discovery cost. When +the agent comes back in a later conversation and the user says "restart the +server," the agent calls {toolref}`list-panes`, finds the pane titled +`server`, and acts -- no searching, no guessing, no capturing every pane to +figure out which one is which. But note: pane IDs are ephemeral across tmux +server restarts, so the agent should always re-discover by metadata (session +name, pane title, cwd) rather than trusting remembered `%N` values. + +**Prompt:** "Set up a tmux workspace for myproject with editor, server, and +test panes." + +--- + +## What to read next + +For the principles that recur across these recipes -- discover before acting, +wait instead of polling, content vs. metadata, prefer IDs, escalate +gracefully -- see the {ref}`prompting guide `. For specific +pitfalls like `enter: false` and the `send_keys`/`capture_pane` race +condition, see {ref}`gotchas `. + diff --git a/docs/tools/index.md b/docs/tools/index.md index 41b057b..334b593 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -2,7 +2,27 @@ # Tools -All tools accept an optional `socket_name` parameter for multi-server support. It defaults to the `LIBTMUX_SOCKET` env var. See {ref}`configuration`. +All tools accept an optional `socket_name` parameter for multi-server support. It defaults to the {envvar}`LIBTMUX_SOCKET` env var. See {ref}`configuration`. + +## Which tool do I want? + +**Reading terminal content?** +- Know which pane? → {tool}`capture-pane` +- Don't know which pane? → {tool}`search-panes` +- Need to wait for output? → {tool}`wait-for-text` +- Only need metadata (PID, path, size)? → {tool}`get-pane-info` + +**Running a command?** +- {tool}`send-keys` — then {tool}`wait-for-text` + {tool}`capture-pane` + +**Creating workspace structure?** +- New session → {tool}`create-session` +- New window → {tool}`create-window` +- New pane → {tool}`split-window` + +**Changing settings?** +- tmux options → {tool}`show-option` / {tool}`set-option` +- Environment vars → {ref}`show-environment` / {ref}`set-environment` ## Inspect diff --git a/docs/tools/options.md b/docs/tools/options.md index 2242c5b..5c3c1b8 100644 --- a/docs/tools/options.md +++ b/docs/tools/options.md @@ -42,6 +42,29 @@ Response: **Side effects:** None. Readonly. +**Example:** + +```json +{ + "tool": "show_environment", + "arguments": {} +} +``` + +Response: + +```json +{ + "variables": { + "SHELL": "/bin/zsh", + "TERM": "xterm-256color", + "HOME": "/home/user", + "USER": "user", + "LANG": "C.UTF-8" + } +} +``` + ```{fastmcp-tool-input} env_tools.show_environment ``` @@ -89,5 +112,27 @@ Response: **Side effects:** Sets the variable in the tmux server. +**Example:** + +```json +{ + "tool": "set_environment", + "arguments": { + "name": "MY_VAR", + "value": "hello" + } +} +``` + +Response: + +```json +{ + "name": "MY_VAR", + "value": "hello", + "status": "set" +} +``` + ```{fastmcp-tool-input} env_tools.set_environment ``` diff --git a/docs/tools/panes.md b/docs/tools/panes.md index d3b34e9..b927b1f 100644 --- a/docs/tools/panes.md +++ b/docs/tools/panes.md @@ -55,6 +55,36 @@ other metadata without reading the terminal content. **Side effects:** None. Readonly. +**Example:** + +```json +{ + "tool": "get_pane_info", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + ```{fastmcp-tool-input} pane_tools.get_pane_info ``` @@ -195,6 +225,37 @@ Keys sent to pane %2 **Side effects:** Changes the pane title. +**Example:** + +```json +{ + "tool": "set_pane_title", + "arguments": { + "pane_id": "%0", + "title": "build" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + ```{fastmcp-tool-input} pane_tools.set_pane_title ``` @@ -207,6 +268,23 @@ Keys sent to pane %2 **Side effects:** Clears the pane's visible content. +**Example:** + +```json +{ + "tool": "clear_pane", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +Pane cleared: %0 +``` + ```{fastmcp-tool-input} pane_tools.clear_pane ``` @@ -219,6 +297,37 @@ Keys sent to pane %2 **Side effects:** Changes pane size. May affect adjacent panes. +**Example:** + +```json +{ + "tool": "resize_pane", + "arguments": { + "pane_id": "%0", + "height": 15 + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + ```{fastmcp-tool-input} pane_tools.resize_pane ``` @@ -234,5 +343,22 @@ without affecting sibling panes. **Side effects:** Destroys the pane. Not reversible. +**Example:** + +```json +{ + "tool": "kill_pane", + "arguments": { + "pane_id": "%1" + } +} +``` + +Response (string): + +```text +Pane killed: %1 +``` + ```{fastmcp-tool-input} pane_tools.kill_pane ``` diff --git a/docs/tools/sessions.md b/docs/tools/sessions.md index 539c3cb..c01a031 100644 --- a/docs/tools/sessions.md +++ b/docs/tools/sessions.md @@ -51,6 +51,27 @@ or inspect server-level state before creating sessions. **Side effects:** None. Readonly. +**Example:** + +```json +{ + "tool": "get_server_info", + "arguments": {} +} +``` + +Response: + +```json +{ + "is_alive": true, + "socket_name": null, + "socket_path": null, + "session_count": 2, + "version": "3.6a" +} +``` + ```{fastmcp-tool-input} server_tools.get_server_info ``` @@ -141,6 +162,23 @@ windows and panes in the session. **Side effects:** Destroys the session and all its contents. Not reversible. +**Example:** + +```json +{ + "tool": "kill_session", + "arguments": { + "session_name": "old-workspace" + } +} +``` + +Response (string): + +```text +Session killed: old-workspace +``` + ```{fastmcp-tool-input} session_tools.kill_session ``` @@ -156,5 +194,20 @@ session, window, and pane. **Side effects:** Destroys everything. Not reversible. +**Example:** + +```json +{ + "tool": "kill_server", + "arguments": {} +} +``` + +Response (string): + +```text +Server killed +``` + ```{fastmcp-tool-input} server_tools.kill_server ``` diff --git a/docs/tools/windows.md b/docs/tools/windows.md index 9e9c71e..0723d7b 100644 --- a/docs/tools/windows.md +++ b/docs/tools/windows.md @@ -67,6 +67,52 @@ sending keys or capturing output. **Side effects:** None. Readonly. +**Example:** + +```json +{ + "tool": "list_panes", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + }, + { + "pane_id": "%1", + "pane_index": "1", + "pane_width": "80", + "pane_height": "8", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12400", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + } +] +``` + ```{fastmcp-tool-input} window_tools.list_panes ``` @@ -164,6 +210,35 @@ Response: **Side effects:** Renames the window. +**Example:** + +```json +{ + "tool": "rename_window", + "arguments": { + "session_name": "dev", + "new_name": "build" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "build", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + ```{fastmcp-tool-input} window_tools.rename_window ``` @@ -177,6 +252,35 @@ Response: **Side effects:** Rearranges all panes in the window. +**Example:** + +```json +{ + "tool": "select_layout", + "arguments": { + "session_name": "dev", + "layout": "even-vertical" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "even-vertical,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + ```{fastmcp-tool-input} window_tools.select_layout ``` @@ -189,6 +293,36 @@ Response: **Side effects:** Changes window size. +**Example:** + +```json +{ + "tool": "resize_window", + "arguments": { + "session_name": "dev", + "width": 120, + "height": 40 + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "baaa,120x40,0,0[120x20,0,0,0,120x19,0,21,1]", + "window_active": "1", + "window_width": "120", + "window_height": "40" +} +``` + ```{fastmcp-tool-input} window_tools.resize_window ``` @@ -203,5 +337,23 @@ Response: **Side effects:** Destroys the window and all its panes. Not reversible. +**Example:** + +```json +{ + "tool": "kill_window", + "arguments": { + "session_name": "dev", + "window_name": "old-logs" + } +} +``` + +Response (string): + +```text +Window killed: old-logs +``` + ```{fastmcp-tool-input} window_tools.kill_window ``` diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md new file mode 100644 index 0000000..b33c42b --- /dev/null +++ b/docs/topics/gotchas.md @@ -0,0 +1,64 @@ +(gotchas)= + +# Gotchas + +Things that will bite you if you don't know about them in advance. For symptom-based debugging, see {ref}`troubleshooting `. + +## Metadata vs. content + +{tool}`list-panes` and {tool}`list-windows` search **metadata** — names, IDs, current command. They do not search what is displayed in the terminal. + +To find text that is visible in terminals, use {tool}`search-panes`. To read what a specific pane shows, use {tool}`capture-pane`. + +This is the most common source of agent confusion. The server instructions already warn about this, but it bears repeating: if a user asks "which pane mentions error", the answer is `search_panes`, not `list_panes`. + +## `send_keys` sends Enter by default + +When you call `send_keys` with `keys: "C-c"`, it sends Ctrl-C **and then presses Enter**. For control sequences, set `enter: false`: + +```json +{"tool": "send_keys", "arguments": {"keys": "C-c", "pane_id": "%0", "enter": false}} +``` + +The `enter` parameter defaults to `true`, which is correct for commands (`make test` + Enter) but wrong for control keys, partial input, or key sequences. + +## `capture_pane` after `send_keys` is a race condition + +`send_keys` returns immediately after sending keystrokes to tmux. It does **not** wait for the command to execute or produce output. + +```json +{"tool": "send_keys", "arguments": {"keys": "pytest", "pane_id": "%0"}} +{"tool": "capture_pane", "arguments": {"pane_id": "%0"}} +``` + +The capture above may return the terminal state **before** pytest runs. Use {tool}`wait-for-text` between them: + +```json +{"tool": "send_keys", "arguments": {"keys": "pytest", "pane_id": "%0"}} +{"tool": "wait_for_text", "arguments": {"pattern": "passed|failed|error", "pane_id": "%0", "regex": true}} +{"tool": "capture_pane", "arguments": {"pane_id": "%0"}} +``` + +See {ref}`recipes` for the complete pattern. + +## Window names are not unique across sessions + +Two sessions can each have a window named "editor". Targeting by `window_name` alone is ambiguous — always include `session_name` or use the globally unique `window_id` (e.g., `@0`, `@1`). + +Pane IDs (`%0`, `%1`, etc.) are globally unique and are the preferred targeting method. + +## Pane IDs are globally unique but ephemeral + +Pane IDs like `%0`, `%5`, `%12` are unique across all sessions and windows within a tmux server. They do not reset when windows are created or destroyed. + +However, they reset when the tmux **server** restarts. Do not cache pane IDs across server restarts. After killing and recreating a session, re-discover pane IDs with {ref}`list-panes`. + +## `suppress_history` requires shell support + +The `suppress_history` parameter on `send_keys` prepends a space before the command, which prevents it from being saved in shell history. This only works if the shell's `HISTCONTROL` variable includes `ignorespace` (the default for bash, but not universal across all shells). + +## `clear_pane` is not fully atomic + +`clear_pane` runs two tmux commands in sequence: `send-keys -R` (reset terminal) then `clear-history` (clear scrollback). There is a brief gap between them where partial content may be visible. + +For most use cases this is not a problem. If you need guaranteed clean state, add a small delay before the next `capture_pane`. diff --git a/docs/topics/index.md b/docs/topics/index.md index bc7cb9e..ebd3569 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -29,6 +29,18 @@ Three-tier safety system for controlling tool access. Symptom-based guide for common issues. ::: +:::{grid-item-card} Gotchas +:link: gotchas +:link-type: doc +Things that will bite you if you don't know about them. +::: + +:::{grid-item-card} Agent Prompting +:link: prompting +:link-type: doc +Write effective instructions for AI agents using tmux tools. +::: + :::: ```{toctree} @@ -37,5 +49,7 @@ Symptom-based guide for common issues. architecture concepts safety +gotchas +prompting troubleshooting ``` diff --git a/docs/topics/prompting.md b/docs/topics/prompting.md new file mode 100644 index 0000000..a3180de --- /dev/null +++ b/docs/topics/prompting.md @@ -0,0 +1,92 @@ +(prompting)= + +# Agent prompting guide + +How to write effective instructions for AI agents using libtmux-mcp. + +## What the server tells your agent automatically + +Every MCP client receives these instructions when connecting to the libtmux-mcp server. You do not need to repeat this information — the agent already knows it. + +```text +libtmux MCP server for programmatic tmux control. tmux hierarchy: +Server > Session > Window > Pane. Use pane_id (e.g. '%1') as the +preferred targeting method - it is globally unique within a tmux server. +Use send_keys to execute commands and capture_pane to read output. All +tools accept an optional socket_name parameter for multi-server support +(defaults to LIBTMUX_SOCKET env var). + +IMPORTANT — metadata vs content: list_windows, list_panes, and +list_sessions only search metadata (names, IDs, current command). To +find text that is actually visible in terminals — when users ask what +panes 'contain', 'mention', 'show', or 'have' — use search_panes to +search across all pane contents, or list_panes + capture_pane on each +pane for manual inspection. +``` + +The server also dynamically adds: +- **Safety tier context**: Which tier is active and what tools are available +- **Caller pane awareness**: If the server runs inside tmux, it tells the agent which pane is its own (via `TMUX_PANE`) + +## Effective prompt patterns + +These natural-language prompts reliably trigger the right tool sequences: + +| Prompt | Agent interprets as | +|--------|-------------------| +| "Run `pytest` in my build pane and show results" | {toolref}`send-keys` → {toolref}`wait-for-text` → {toolref}`capture-pane` | +| "Start the dev server and wait until it's ready" | {toolref}`send-keys` → {toolref}`wait-for-text` (for "listening on") | +| "Check if any pane has errors" | {toolref}`search-panes` with pattern "error" | +| "Set up a workspace with editor, server, and tests" | {toolref}`create-session` → {toolref}`split-window` (x2) → {toolref}`set-pane-title` (x3) | +| "What's running in my tmux sessions?" | {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`capture-pane` | +| "Kill the old workspace session" | {toolref}`kill-session` (after confirming target) | + +## Anti-patterns to avoid + +| Prompt | Problem | Better version | +|--------|---------|---------------| +| "Run this command" | Ambiguous — agent may use its own shell instead of tmux | "Run `make test` in a tmux pane" | +| "Check my terminal" | Which pane? Agent must discover first | "Check the pane running `npm dev`" or "Search all panes for errors" | +| "Clean up everything" | Too broad for destructive operations | "Kill the `ci-test` session" | +| "Show me the output" | Capture immediately? Or wait? | "Wait for the command to finish, then show me the output" | + +## System prompt fragments + +Copy these into your agent's system instructions (`CLAUDE.md`, `.cursorrules`, or MCP client config) to improve behavior: + +### For general tmux workflows + +```text +When executing long-running commands (servers, builds, test suites), +use tmux via the libtmux MCP server rather than running them directly. +This keeps output accessible for later inspection. Use the pattern: +send_keys → wait_for_text (for completion signal) → capture_pane. +``` + +### For safe agent behavior + +```text +Before creating tmux sessions, check list_sessions to avoid duplicates. +Always use pane_id for targeting — it is globally unique. Never run +destructive operations (kill_session, kill_server) without confirming +the target with the user first. +``` + +### For development workflows + +```text +When the user asks you to run tests or start servers, use dedicated +tmux panes. Split windows to run related processes side-by-side. +Use wait_for_text to know when a server is ready before running tests +that depend on it. +``` + +## Tool selection heuristics + +When an agent is unsure which tool to use, these rules help: + +1. **Discovery first**: Call {toolref}`list-sessions` or {toolref}`list-panes` before acting on specific targets +2. **Prefer IDs**: Once you have a `pane_id`, use it for all subsequent calls — it never changes during the pane's lifetime +3. **Wait, don't poll**: Use {toolref}`wait-for-text` instead of repeatedly calling {toolref}`capture-pane` in a loop +4. **Content vs. metadata**: If looking for text *in* a terminal, use {toolref}`search-panes`. If looking for pane *properties* (name, PID, path), use {toolref}`list-panes` or {toolref}`get-pane-info` +5. **Destructive tools are opt-in**: Never kill sessions, windows, or panes unless the user explicitly asks diff --git a/src/libtmux_mcp/__about__.py b/src/libtmux_mcp/__about__.py index 5bf5182..4ed7017 100644 --- a/src/libtmux_mcp/__about__.py +++ b/src/libtmux_mcp/__about__.py @@ -14,4 +14,4 @@ __url__ = "https://libtmux-mcp.git-pull.com" __bug_tracker__ = "https://github.com/tmux-python/libtmux-mcp/issues" __repository__ = "https://github.com/tmux-python/libtmux-mcp" -__changes__ = "https://github.com/tmux-python/libtmux-mcp/blob/master/CHANGES" +__changes__ = "https://github.com/tmux-python/libtmux-mcp/blob/main/CHANGES" diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index 2562225..340b8b5 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -63,7 +63,9 @@ def _build_instructions(safety_level: str = TAG_MUTATING) -> str: "Available tiers: 'readonly' (read operations only), " "'mutating' (default, read + write + send_keys), " "'destructive' (all operations including kill commands). " - "Set via LIBTMUX_SAFETY env var." + "Set via LIBTMUX_SAFETY env var. " + "Tools outside the active tier are hidden and will not appear in " + "tool listings." ) # Agent tmux context diff --git a/src/libtmux_mcp/tools/env_tools.py b/src/libtmux_mcp/tools/env_tools.py index 48221e5..04431e5 100644 --- a/src/libtmux_mcp/tools/env_tools.py +++ b/src/libtmux_mcp/tools/env_tools.py @@ -27,6 +27,8 @@ def show_environment( ) -> EnvironmentResult: """Show tmux environment variables. + Use to inspect tmux environment variables that affect child processes. + Parameters ---------- session_name : str, optional @@ -66,6 +68,9 @@ def set_environment( ) -> EnvironmentSetResult: """Set a tmux environment variable. + Use to set variables that will be inherited by new panes and windows. + Changes do not affect already-running processes. + Parameters ---------- name : str diff --git a/src/libtmux_mcp/tools/option_tools.py b/src/libtmux_mcp/tools/option_tools.py index 8dc23a8..acaf911 100644 --- a/src/libtmux_mcp/tools/option_tools.py +++ b/src/libtmux_mcp/tools/option_tools.py @@ -73,6 +73,9 @@ def show_option( ) -> OptionResult: """Show a tmux option value. + Use to check tmux configuration values such as history-limit, + mouse support, or status bar settings. + Parameters ---------- option : str @@ -109,6 +112,9 @@ def set_option( ) -> OptionSetResult: """Set a tmux option value. + Use to change tmux behavior at runtime. Common uses: adjusting + history-limit, enabling mouse support, changing status bar format. + Parameters ---------- option : str diff --git a/src/libtmux_mcp/tools/pane_tools.py b/src/libtmux_mcp/tools/pane_tools.py index ce79ab1..7fb3cc7 100644 --- a/src/libtmux_mcp/tools/pane_tools.py +++ b/src/libtmux_mcp/tools/pane_tools.py @@ -40,6 +40,10 @@ def send_keys( ) -> str: """Send keys (commands or text) to a tmux pane. + After sending, use wait_for_text to block until the command completes, + or capture_pane to read the result. Do not capture_pane immediately — + there is a race condition. + Parameters ---------- keys : str @@ -147,6 +151,8 @@ def resize_pane( ) -> PaneInfo: """Resize a tmux pane. + Use when adjusting layout for better readability or to fit content. + Parameters ---------- pane_id : str, optional @@ -205,6 +211,9 @@ def kill_pane( ) -> str: """Kill (close) a tmux pane. Requires exact pane_id (e.g. '%5'). + Use to clean up panes no longer needed. To remove an entire window + and all its panes, use kill_window instead. + Parameters ---------- pane_id : str @@ -245,6 +254,8 @@ def set_pane_title( ) -> PaneInfo: """Set the title of a tmux pane. + Use titles to label panes for later identification via list_panes or get_pane_info. + Parameters ---------- title : str @@ -287,6 +298,9 @@ def get_pane_info( ) -> PaneInfo: """Get detailed information about a tmux pane. + Use this for metadata (PID, path, dimensions) without reading terminal content. + To read what is displayed in the pane, use capture_pane instead. + Parameters ---------- pane_id : str, optional @@ -326,6 +340,9 @@ def clear_pane( ) -> str: """Clear the contents of a tmux pane. + Use before send_keys + capture_pane to get a clean capture without prior output. + Note: this is two tmux commands with a brief gap — not fully atomic. + Parameters ---------- pane_id : str, optional diff --git a/src/libtmux_mcp/tools/server_tools.py b/src/libtmux_mcp/tools/server_tools.py index e111247..51d2deb 100644 --- a/src/libtmux_mcp/tools/server_tools.py +++ b/src/libtmux_mcp/tools/server_tools.py @@ -31,6 +31,9 @@ def list_sessions( ) -> list[SessionInfo]: """List all tmux sessions. + Use as the starting point for discovery — call this before targeting + specific sessions, windows, or panes. + Parameters ---------- socket_name : str, optional @@ -61,6 +64,9 @@ def create_session( ) -> SessionInfo: """Create a new tmux session. + Check list_sessions first to avoid name conflicts. A new session + starts with one window and one pane. + Parameters ---------- session_name : str, optional @@ -105,6 +111,10 @@ def create_session( def kill_server(socket_name: str | None = None) -> str: """Kill the tmux server and all its sessions. + Destroys ALL sessions, windows, and panes on this server. Use kill_session + to remove a single session instead. Self-kill protection prevents killing + the server running this MCP process. + Parameters ---------- socket_name : str, optional @@ -134,6 +144,9 @@ def kill_server(socket_name: str | None = None) -> str: def get_server_info(socket_name: str | None = None) -> ServerInfo: """Get information about the tmux server. + Use to verify the tmux server is running before other operations. + For session-level details, use list_sessions instead. + Parameters ---------- socket_name : str, optional diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 7d482b6..1fd1b30 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -81,6 +81,8 @@ def create_window( ) -> WindowInfo: """Create a new window in a tmux session. + Creates a window with one pane. Use split_window to add more panes afterward. + Parameters ---------- session_name : str, optional @@ -137,6 +139,9 @@ def rename_session( ) -> SessionInfo: """Rename a tmux session. + Use when a session's purpose has changed. Existing pane_id references + remain valid after renaming. + Parameters ---------- new_name : str @@ -167,6 +172,10 @@ def kill_session( ) -> str: """Kill a tmux session. + Destroys the session and all its windows and panes. Use kill_window + to remove a single window instead. Self-kill protection prevents + killing the session containing this MCP process. + Parameters ---------- session_name : str, optional diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 7896a18..548a04a 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -111,6 +111,9 @@ def split_window( ) -> PaneInfo: """Split a tmux window to create a new pane. + Creates a new pane by splitting an existing one. Use direction to choose + above/below/left/right. Returns the new pane's info including its pane_id. + Parameters ---------- pane_id : str, optional @@ -188,6 +191,9 @@ def rename_window( ) -> WindowInfo: """Rename a tmux window. + Use when a window's purpose has changed. Existing window_id references + remain valid after renaming. + Parameters ---------- new_name : str @@ -227,6 +233,10 @@ def kill_window( ) -> str: """Kill (close) a tmux window. Requires exact window_id (e.g. '@3'). + Destroys the window and all its panes. Use kill_pane to remove a single + pane instead. Self-kill protection prevents killing the window containing + this MCP process. + Parameters ---------- window_id : str @@ -272,6 +282,9 @@ def select_layout( ) -> WindowInfo: """Set the layout of a tmux window. + Choose from: even-horizontal, even-vertical, main-horizontal, + main-vertical, or tiled. Rearranges all panes in the window. + Parameters ---------- layout : str @@ -319,6 +332,8 @@ def resize_window( ) -> WindowInfo: """Resize a tmux window. + Use to adjust the window dimensions. This affects all panes within the window. + Parameters ---------- window_id : str, optional