diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b7394e9c..b9274c5e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: python-version: ['3.14'] - tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', 'master'] + tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7c', 'master'] steps: - uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index 534862c9..68c884e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,11 @@ what was asked for. | ---- | ---------- | | `src/libtmux_mcp/server.py` | FastMCP instance: construction, instructions, lifespan | | `src/libtmux_mcp/middleware.py` | Safety-tier gating, response limiting, error mapping | -| `src/libtmux_mcp/_utils.py` | Server cache, object resolvers/serializers, `handle_tool_errors` | +| `src/libtmux_mcp/_errors.py`, `_safety.py`, `_guards.py`, `_exec.py` | Error shaping, safety tiers, argument guards, bounded tmux execution | +| `src/libtmux_mcp/_caller.py`, `_servers.py`, `_resolve.py`, `_pane_state.py`, `_serialize.py` | Caller context, server cache, object resolution, pane state, serialization | +| `src/libtmux_mcp/_tmux_proc.py`, `_bounded_io.py`, `_progress.py`, `_patterns.py`, `_history.py`, `_wait_policy.py` | Async process, wait, capture, and history internals | | `src/libtmux_mcp/models.py` | Pydantic models for tool outputs | -| `src/libtmux_mcp/tools/` | MCP tool implementations: one module per tmux object, plus batch/buffer/hook/wait_for | +| `src/libtmux_mcp/tools/` | MCP tools by tmux object; pane tools are split by I/O, waits, capture, search, layout, lifecycle, metadata, and pipes | | `src/libtmux_mcp/resources/` | `tmux://` URI resources for browsing the hierarchy | | `src/libtmux_mcp/prompts/` | MCP prompt templates | | `scripts/mcp_swap.py` | Dev script: point agent CLI configs at a local checkout | diff --git a/CHANGES b/CHANGES index 1db34af0..b8052460 100644 --- a/CHANGES +++ b/CHANGES @@ -1,11 +1,3171 @@ # Changelog - ## libtmux-mcp 0.1.x (unreleased) _Notes on upcoming releases will be added here_ +### Breaking changes + +- **A name or title argument is now a literal.** `rename_window`, + `rename_session`, `set_pane_title`, `create_window` and `create_session` + escape tmux format sequences before dispatch, so a `#{...}` in a name is + stored rather than expanded. Callers relying on the expansion should call + `display_message` and pass the result. +- **An option name containing `#` is refused.** `set_option` and + `show_option` raise instead of addressing a different option. Option + *values* are unaffected. + +- **Tools that deliver input require an explicit target.** `send_keys`, + `send_keys_batch`, `paste_text`, `paste_buffer` and `run_command` no + longer choose a pane. See MIGRATION for the one-field fix. +- **An untargeted read resolves to the OLDEST object**, not the first + listed, and the option and hook tools stop using tmux's + activity-based rule. A destination that used to follow renames or + pane output now holds still. +- **Prompt arguments that name an object are validated.** `pane_id` + must match `%N`; `session_name` may not be empty or hold control + characters, `:` or `.`. + +### What's new + +#### The environment tools answer scope questions like their siblings + +`show_option` documents "not set AT THIS SCOPE, not 'not set'" and +takes `include_inherited`. `show_hook` gained the same this release. +The environment trio was the last of the option-shaped family without +it, and the gap was reachable rather than theoretical: unsetting a +GLOBALLY-set variable at session scope removed nothing, reported +`status="absent"` -- the same answer a name that never existed gets -- +and every new pane in that session kept receiving it. + +`unset_environment` now reports `still_set_globally`, which separates +"there was nothing to remove" from "it is still in force everywhere". +`show_environment` takes `include_inherited`, merging the global set +underneath a session's with session values winning, which is the +precedence a spawned pane actually sees -- so "what will the next pane +get" is now one call rather than two plus a merge. + +Reported by the QA instance, which measured the concrete harm (a +"removed" variable arriving in a fresh pane). One half of the report did +not reproduce: the global environment is not invisible to the tool +surface -- `show_environment` with no session reads it and says +`scope_queried="global"`. The gap was the conflation, not the access. + +#### `copy_selection` reads a person's copy-mode selection + +An agent could enter copy mode and perform a copy, and then never read +it: the result lands in tmux's own `buffer0`, and `show_buffer` +correctly refuses any buffer this server did not allocate, because tmux +buffers may hold clipboard history. The workaround was +`run_command("tmux show-buffer -b buffer0")`, which routes around the +boundary rather than respecting it. + +The value is not the agent's own copy -- `capture_pane` already returns +pane text. It is the case `capture_pane` cannot reach: a person attached +to the session highlights something and asks about *that*, not about the +pane. Measured against a real attached client, a selection survives idle +time, cursor movement and further process output arriving on the pane's +tty; only leaving copy mode clears it. So there is nothing to race. + +`copy_selection` copies into a fresh MCP-namespaced buffer and returns +the same `BufferContent` shape `show_buffer` does, so the result is +usable immediately and the `buffer_name` still composes with +`paste_buffer` for moving a selection between panes. The selection is +left intact -- reading it must not disturb the person who made it -- and +the buffer privacy boundary is not widened: the copied-and-exited case +stays unreachable. + +Two refusals are loud on purpose. The pane guard reads `pane_mode` +rather than `pane_in_mode`, because `choose-tree` and the other modes +also set the flag. And a copy with nothing selected is refused because +tmux EXITS 0 for it and creates no buffer at all -- without the check +the tool would report success and hand back a buffer name that does not +exist. + +`copy_selection` requires **tmux >= 3.4** and refuses below it. On +3.2a and 3.3a, `copy-selection` kills the tmux SERVER -- not the +command, not the pane -- taking every session on it. Four conditions +have to hold together and all four hold in a default install: tmux +3.2a/3.3a, an attached client, `set-clipboard` on (the default is +`external` on every version 3.2a..3.7c), and a terminal that advertises +clipboard support. That conjunction is precisely this tool's use case, +since reading a person's selection means an attached client in a real +terminal at default settings -- so the configurations that survive are +the ones with nobody in them, and documenting the hazard would not +avoid it. + +Refusing rather than working around it. `set-clipboard off` for the +duration of the copy does prevent the crash, but it mutates a +server-global, user-visible option around every call and two concurrent +calls would race to restore it. From tmux 3.6 the copy passes `-C` +instead, which suppresses the clipboard write outright -- worth doing +on its own account, since an agent reading a person's selection should +not also overwrite that person's system clipboard. + +Prompted by the QA instance, which also established the persistence +property from inside a real attached client, something the headless test +suite structurally cannot observe -- and then caught the crash on the +shipped commit by running it against all nine supported tmux versions, +which is the check neither the test suite nor a source reading could +have made. + +#### `show_hook` can answer with the hook actually in force + +`show_hook` returned zero entries for a hook set with `set-hook -g` -- +a hook that is in force and WILL fire. tmux's `show-hooks ` does +not consult wider scopes, so the tool was faithful to tmux and still +told an agent "no" to "is this hook set?". + +That is the trap `show_option` already documents and already solves, one +module over: "not set AT THIS SCOPE, not 'not set'", with +`include_inherited` for tmux's `-A`. `show_hook` now takes the same +flag and its docstring carries the same warning, so the two +option-shaped tools answer the same question the same way. + +The flag routes through the OPTIONS lookup, because that is the only one +of tmux's two name lookups that honours `-A` -- with `set-hook -g +alert-bell` set, `show-hooks -t A alert-bell` is empty while +`show-options -A -t A alert-bell` returns it. tmux keeps hooks in the +options table, so these are the same store reached two ways. +`HookListResult.include_inherited` reports which lookup answered. + +#### A name argument means what it says + +tmux expands `#{...}` in the name argument of `rename-window`, +`rename-session`, `select-pane -T` and `new-session`, and gives no flag to +turn it off -- unlike `set-option` and `set-environment`, which gate the +same expansion behind `-F`. This server inherited the split without naming +it, so five tools stored the expansion of a name instead of the name: + + rename_window(new_name="#{pane_current_path}") + -> window named "/home/you/src" + +That reaches further than a wrong string. `#{host}`, `#{pane_pid}` and +friends interpolate server state into a value that is visible in the +terminal and returned by `list_panes`, at the *mutating* tier. + +It also defeated the input validator, which is the worse half. +`create_session` validates the caller's literal; tmux expanded it +afterwards and ran the result through `clean_name`, so +`session_name="#{pane_current_path}"` reduced to the EMPTY name the +validator exists to reject. The session came back reporting success, could +not be addressed by name, and could not even be renamed back -- only its +`session_id` reached it. + +The escape is not "double every `#`". A `#`-run followed by `[` is a style +sequence that tmux copies verbatim and never collapses, so doubling there +corrupts the value; the unit that gets escaped is the maximal run. And +there are two expanders that disagree about `%`: only `pipe-pane` reaches +the strftime-applying one, so doubling `%` -- correct there -- would +corrupt `select-pane -T '%Y-%m-%d'`. `libtmux_mcp._tmux_format` now names +both, and `pipe_pane`'s escaping moved onto the shared implementation. + +Option *names* are refused rather than escaped. Escaping works at the tmux +layer, but libtmux keys its `show-options` result by the name the caller +passed while tmux answers under the name it stored, so an escaped name +reads back as "not set" forever. Writable and permanently unreadable is +worse than rejected. + +Found by the QA instance, which also supplied the tmux source citations; +the sweep of tmux's own command table then turned up `new-session -s/-n/-c` +and the option-name case on top of the five reported. + +#### The client/server version boundary is documented + +`docs/installation.md` declared tmux >= 3.2a, which is the floor for the +tmux **this server runs**. There is a second constraint it never +mentioned, because tmux sockets outlive the binary that made them: a +client can only talk to a server whose protocol it understands. + +Measured across all 81 pairs of the nine supported versions, checked +against the tmux binary directly and then through this server: + + server 3.6+, client 3.5 and older unreachable (20 of 81) + every other pair fine + +A clean rectangle, and one-directional — 3.6+ clients read every server +including old ones. You hit it when a system tmux upgrade leaves a +session running under the old binary. It already surfaces honestly +rather than as an empty result, which is what the resource and resolver +fixes above were for; the gap was that nothing told a reader the +boundary exists or which binary to point at. + +Mapped by the QA instance, which also closed its own open gap in the +process: its unreachable-server fixture had stopped reproducing because +it was building an OLD server and pointing a NEW client at it -- the +top-right of that table, which is entirely reachable. The case needs +the newer side to be the server. + +#### An unset user option reads the same on every supported tmux + +`docs/installation.md` declares tmux 3.2a and up, and there is no +version branching in this server -- so every tool is claimed to behave +identically across that range. `show_option` did not: + + tmux 3.2a rc 0, no value -> value=None + tmux 3.3a .. 3.7c rc 1, invalid option -> ERROR + +A clean boundary at 3.3a, on the DECLARED FLOOR versus everything else. +A caller writing "read this option, treat absent as unset" worked on +3.2a and raised on the other eight builds; one writing the try/except +found it dead code on the floor. + +Normalised toward the floor: "not set" is an ordinary answer to "what +is this option", and an exception is a poor way to say it. + +Narrow on purpose. An unset user option and a MISTYPED built-in give +the identical tmux message -- `@unset_probe` and `notarealoption` both +produce `invalid option: ` -- so the only thing separating them +is tmux's own rule that user options begin with `@`. A typo in a +built-in still raises, which is the failure a caller needs to see. + +Found by the QA instance sweeping all 60 tools against the declared +floor with a modern control. It was the only version-specific +divergence in the sweep, which is what makes it worth naming rather +than a symptom of wider drift. + +#### Three tool descriptions that left a caller to guess + +A tool's docstring becomes its MCP schema description, and that is what +an agent reads when choosing between neighbours -- not the docs. + +`get_pane_info` said "to read what is displayed, use `capture_pane` +instead", while the server instructions say "prefer `snapshot_pane` +over `capture_pane` + `get_pane_info`". Same question, two answers. The +tool was right for *content instead of metadata* and the instructions +for *content and metadata*, but the phrasing did not carve that, so a +caller reading only the schema was steered into the two-call pattern +the instructions exist to prevent. It now names both cases. Swept the +rest: `wait_for_text`'s steer away from polling `capture_pane` is about +waiting, not about metadata, and is correct as written. + +`paste_buffer` was 38 characters and `delete_buffer` 27, in a family +where every sibling explains itself -- `paste_text` even documents when +to prefer them, and got nothing back. Both now say what they are for, +when to prefer them over `paste_text`, and (for `delete_buffer`) that +it is unnecessary after the paths that clean up after themselves. + +Found by comparing the per-tool descriptions against the server-level +instructions rather than reading either alone. Each was sensible on its +own, which is why an audit of either surface passes it -- the same +shape as the untargeted-resolution seam. + +#### Validation refusals say what you sent + +Most of this server already echoes the offending value -- `offset`, +`limit`, `scroll_up` and the batch `timeout` all report "received X". +Five refusals stated the rule and stopped there, so a caller who typo'd +`on_error="STOP"` was told the valid values and left to spot their own +mistake: + + on_error must be 'stop' or 'continue' + timeout must be positive + scope is required when target is specified + Cannot combine zoom with height/width + +All five now name the value, and the scope ones also list what is +valid. Found by auditing every error message in the tree for two +properties a caller needs: the offending value, and what to do next. + +#### `run_command` reaches tmux through a subprocess, not a thread + +The last instance of the class. Its reads went through +`asyncio.to_thread`, and a thread blocked in libtmux's untimed +`Popen.communicate()` cannot be cancelled -- so a tmux server that +answered once and then stopped answering left the call unable to return +and the process unable to exit. + +The resolve, the busy check, both foreground-command reads and the +final capture are now the killable subprocess. Against a half-wedge it +bounds and names the read that was outstanding: + + tmux list-panes did not return within 5.00s + +`_run_send_keys` deliberately stays in a thread. It already runs each +argv under a 5-second bound, so its worker always returns -- bounded +work in a thread is safe, and the hazard is only the untimed call. That +also leaves the `--` argv discipline untouched, which was the part +least worth rewriting; the id-addressed builder delegates to the same +body rather than copying it. + +Verified independently by the QA instance across fourteen rows and two +variants, every one bounded, against a control where the same fixture +hangs the pre-conversion code past 45 seconds. The bound names a +different tmux command as the wedge is pushed later into the sequence +-- `list-panes`, then `display-message`, then `send-keys` -- which is +what advancing through the I/O actually looks like. + +Both halves are independently confirmed. The call no longer hanging is +the table above; that the PROCESS can now exit was checked by a second +instrument, separately built, which hangs on both pre-conversion pins +in six rows and exits clean on both converted ones in six more -- +having first been shown to exit clean when it arms the wedge and calls +no tool at all. + +That last check is the one nobody was running. Every fixture in this +work was validated by showing it COULD produce the positive; this one +also had to be shown NOT to produce it unconditionally. Two earlier +versions failed one direction each -- one hung whatever it was pointed +at, the other could not reach the defect because the bounded liveness +probe caught its wedge first. + +The busy check split into the reads and the rules, so the async path +takes the same rules over state it read safely rather than a second +copy of them. + +#### `run_command` read its exit status on the event loop + +Two `Pane.cmd` calls sat inline in an async body -- reading the exit +status the wrapper had just written, and clearing it. `Pane.cmd` is a +tmux round trip with no timeout, so every OTHER in-flight call waited +with them, and against a server that had stopped answering, waited +indefinitely. + +Invisible to the structural guard as written, because the offending +call is an attribute access on whatever object is in hand rather than a +name it could match. The guard now covers `cmd`, `capture_pane`, +`display_message` and `refresh` on any receiver; putting one back names +the file, line and method. + +#### `capture_since` can no longer take the process down with a wedged tmux + +A tmux server that answers once and then stops answering left +`capture_since` unable to return AND the process unable to exit. Its +tmux reads went through `asyncio.to_thread`, and a thread blocked in +libtmux's untimed `Popen.communicate()` cannot be cancelled -- +`concurrent.futures.thread._python_exit` joins pool workers untimed at +shutdown, so Ctrl-C and normal exit both hang. + + before killed at 120s, no output + after returns, and the process exits + +Seeing it at all needs a socket that forwards its FIRST connection and +stalls the rest: one that never answers is caught by the bounded +liveness probe, so the unbounded call behind it is never reached and +the fixture comes back confidently clean. + +No loop-gap test could have found it either. The event loop keeps +ticking throughout -- 16,459 ticks across a 90-second hang, measured +independently -- because nothing is blocked; the call simply never +returns. + +Every read now goes through the killable subprocess the wait path +already used, so this is the shape `wait_for_text` has had all along +rather than a new mechanism. `run_command` still has the old one, and +`_tmux_proc`'s docstring now names it rather than stating the +constraint as though the tree already complied everywhere. + +#### `split_window` says what it did to the pane it split + +`size` names the NEW pane, and the result described only the new pane. +So each split's SOURCE geometry -- the number that constrains the next +split of the same region -- was the one thing the response did not +carry. + +Building three equal columns across 236 needs the 157-column remainder, +not the 78-column pane that is already right: + + split B size=78 -> new pane 78, source_pane 157 <- was missing + +Without it a chain of N splits needs N-1 `list_panes` round trips to +recover a value the server already had in hand, and a caller who does +not know to make them gets the wrong layout SILENTLY, because every +individual response was true. + +`SplitResult` extends `PaneInfo` rather than nesting it, so every field +a caller already reads stays exactly where it was. `source_pane` is +re-read after the split, not echoed: the in-hand object still describes +the pre-split geometry, which is the number a caller must not plan +with. + +Argued by the same agent session, after the pre-flight below had +already obsoleted its first argument for this. The second one does not +overlap with it at all -- it is about planning the next call in the +range where nothing is wrong and nothing gets refused. + +#### A split that flattens the pane it splits is refused + +`size` names the NEW pane, the way tmux's `-l` does, and `split_window` +returns only that pane. So a caller who got the arithmetic backwards was +handed a correct-looking result while their own pane was destroyed. +Measured on an 80-column pane: + + size=78 new pane 78, source 1 honoured + size=79 .. 1e6 new pane 78, source 1 SILENTLY CLAMPED + +The clamped rows are what is refused now, naming the largest size that +fits. A faithful split that happens to leave a narrow source is still +allowed: that is the caller's layout to choose and the report is true. + +Percentages go through the same check -- `99%` of 80 columns is 79. + +The parameter description now says `size` means the new pane and that +the source keeps `extent - size - 1`, since the name alone reads either +way. Contrast `resize_pane`, which takes `pane_id` plus `width`/`height` +and so names its subject. + +Reported by an unrelated agent session that hit it splitting real panes +and was left with a one-column sliver. + +#### `run_command` and `wait_for_channel` report progress + +`wait_for_text` polls, so it reports elapsed and remaining seconds from +inside its own loop. The other two waits each await ONE `tmux wait-for` +child, so a client watching a thirty-second `run_command` saw the same +thing whether the command was running or the tmux server had stopped +answering. + +A ticker beside the wait closes that, once a second: + + Running in pane %0: 2.0s elapsed, 8.0s left + Waiting on channel deploy_ok: 2.0s elapsed, 3.0s left + +It does not fire on a call that returns inside the first second, so a +sub-second wait still puts nothing on the wire. + +The ticker is cancelled and awaited in a `finally`, and its own +`CancelledError` is suppressed so it cannot replace the one the caller +is propagating — `run_command` is the most-cancelled wait in the tree +and its cancellation semantics are the thing least worth breaking for a +notification. + +#### One wedged socket no longer stalls every concurrent call + +Resolving a server shells out to tmux to check the socket answers -- +about 4 ms against a healthy one, the full liveness bound against one +that never replies. The async tools called it straight from the event +loop, so that wait was paid by every OTHER call in flight, not just the +one that asked. Measured against a wedged socket: `capture_since` held +the loop for 5.01 s and a ticker beside it advanced exactly once. + +The five async tools resolve off-loop now. Same measurement: 535 ticks. + +Through an async SUBPROCESS, not a worker thread. The wait path forbids +threads outright -- `concurrent.futures.thread._python_exit` joins them +with no timeout, so one wedged tmux would hang interpreter exit forever +-- and a first attempt using `asyncio.to_thread` was caught by the test +that guards it. + +The blocking predates the bound -- the cached path always shelled out +-- but a bounded five-second stall shared by every concurrent caller is +still a stall, and the async tools are the ones with company. + +Pane resolution had the same shape one step further in, and no bound at +all: `Server.cmd` has no timeout, so a server that answered the +liveness probe and then wedged would block every concurrent caller +there instead. A structural test now reads the tree for tmux work +called inline from an async body, because a behavioural test catches +only the helper it stubs and there were three separate call sites. + +The behavioural half asserts by PRESENCE, not by timing. Its first +version measured the gap between event-loop ticks, and the parallel +gate caught it false-firing at loadavg 43: scheduler starvation +produced gaps of 0.42-0.54s, indistinguishable by magnitude from the +0.3s block it was watching for. A stall the machine can fake is not a +signal. + +#### `filters` is redacted from the audit log + +`filters` is a caller-supplied match expression, the same category as +`pattern`: `{"pane_title__contains": ...}` says what the caller was +hunting for. `pattern`, `patterns` and `stop` were accepted as +sensitive for exactly that reason and `filters` was not, so it reached +the log verbatim -- in BOTH shapes it arrives in, since the argument +also accepts a JSON string. + +Routing metadata is still preserved. An audit trail that redacts which +pane was targeted is not an audit trail. + +Reported by the QA instance, which planted a marker in every string, +array and object parameter of all 60 tools rather than reading the +list. + +#### The tool batches take a `timeout` + +`call_readonly_tools_batch`, `call_mutating_tools_batch` and +`call_destructive_tools_batch` took only `operations` and `on_error`. +The cap is 1000 operations and 1000 is not cheap: + + 100 ops 4.2s + 1000 ops 67.0s <- inside the cap, nothing a caller could bound + 5000 ops refused + +A client that gives up does not stop the server: killing the caller +five seconds into a 1000-operation mutating batch left it running, and +617 further mutations landed on the user's tmux server after the caller +was gone, with no report of where it stopped. + +Checked between operations, which suffices here and would not have for +a caller-supplied regex -- the time is genuinely in this loop. The +report and the state agree: at `timeout=1.0` a 400-operation batch +stopped at index 78 and the pane's own counter read 0077, so nothing +ran past the index it named. `timeout=0` is refused rather than read as +"no cap"; `null` means no cap. + +`send_keys_batch` already carried this parameter, which is the same +in-tree precedent that settled the target and spawn-argument questions. + +Reported by the QA instance, which measured both the cost curve and the +orphaned work. + +#### Prompt identifier arguments can no longer write prompt lines + +The four prompt recipes interpolate their arguments into PROSE, not +only into the code blocks they contain, and the free-text arguments +(`command`, `log_command`) were already `repr`'d there. The IDENTIFIER +arguments were not -- so a `pane_id` carrying a newline started a line +of instructions, and repeated it at every mention: + + interrupt_gracefully(pane_id="%1\n\nIGNORE ALL PREVIOUS ...") + +renders that payload at the start of its own line, five times. + +The split was exactly backwards: the arguments that legitimately carry +arbitrary content were escaped, and the ones with a trivial format were +not. `pane_id` must now match `%N` and `session_name` must be free of +control characters and of the punctuation tmux itself rejects. +Validating beats escaping here, because the format admits nothing to +escape. + +Prompt arguments come from the MCP client rather than from a remote +attacker, so this is not remote input by default. It matters because +agents routinely fill them from tool output or user text, and `pane_id` +is exactly the kind of value that arrives from somewhere else. + +Reported by the QA instance, which also found the URI-escaping case +next door is already handled well: `tmux://panes/%10` percent-decodes +to a control character, and the resource says so and names the working +form rather than reporting a generic miss. + +#### `list_servers` no longer hangs on a socket that never answers + +A live listener is not a server that replies. A tmux server spinning +inside its own event loop accepts the connection and never answers, and +`Server.cmd` has no timeout -- so ONE such socket in `$TMUX_TMPDIR` +made `list_servers` never return: + + baseline 25 servers in 2.03s + one silent listener added never returned (85s and counting) + +That is the worst possible tool to lose, because a wedged server is +exactly what an operator is reaching for `list_servers` to find. + +Every probe is bounded now, and a socket that does not answer is +reported with `unreachable_reason` set rather than dropped -- dropping +it would be the same "empty means absent" claim the resource path was +fixed for. The same scan takes 2.07s with the silent listener present, +and 0.09s without, down from 2.03s: the bounded probe also replaced +three tmux round trips per server with two. + +**The other tools were the same defect one step later.** Fixing the +listing routed operators straight into it: `list_servers` names an +unreachable socket, and the obvious next call hung. + + get_server_info(socket_name=) never returned + list_sessions(socket_name=) never returned + capture_pane(pane_id='%0', socket_name=...) never returned + +`capture_pane` is the one that shows it is not a `list_servers` +problem: it never probed liveness at all, it just resolved a pane +through `Server.cmd`, which has no timeout. + +The bound now sits in `_get_server`, which every tool funnels through, +so a socket that accepts and never answers is refused before anything +downstream touches it. All three now return in 5.00s with a message +saying the server is wedged rather than absent -- its sessions are not +lost, but nothing can reach them until it is killed. A DEAD socket is +unaffected: it answers "no server running" immediately and every tool +reports it exactly as before -- which the regression test asserts +alongside the refusal, because a guard that refused every socket would +satisfy the first half on its own. + +Found while chasing an unrelated 286-second test, which turned out to +be measuring the QA instance's wedged tmux servers rather than anything +in the suite. + +#### Tools that type into a pane no longer pick the pane + +`send_keys`, `send_keys_batch`, `paste_text`, `paste_buffer` and +`run_command` accepted no target at all and delivered the caller's +input to a pane chosen by list order. Reads may default; typing may +not -- and a stable default is not a correct one, since nothing in the +call says which pane was meant. + +The precedent is in this same server: `kill_window` requires +`window_id`, so the destructive tools already refuse to guess. There +was no principled reason `send_keys` got to, and it is the one that +executes something. The destination was disclosed in the result, but +that arrives after the keystrokes have landed. + +A batch is checked per operation, since one untargeted entry among +targeted ones is what a whole-batch check would miss. For +`run_command` the check sits after the multi-line refusal, which keeps +a breakout payload away from tmux entirely and whose ordering is +deliberate. + +`SessionInfo` and `WindowInfo` already carry `active_pane_id`, so +explicit targeting after a `create_session` or `create_window` is one +field, not a follow-up `list_panes`. + +#### "No target" now means one thing, and it stays put + +An untargeted read was resolved by two different rules in the same +server, chosen by whether the caller happened to name something: + + show_option / show_hooks / show_hook omitted -t; tmux resolved + everything else first LISTED object + +Neither rule holds still. tmux's picks by `activity_time`, so the +destination moved whenever any pane produced output. Ours took the +first listed session, and tmux lists sessions BY NAME -- so +`rename_session` silently redirected every later untargeted call into a +*different* session, with nothing about that session having changed. + +The default is now the oldest surviving object by tmux id, which no +later call can move, and the option and hook tools resolve it through +the same resolver as everything else -- so no read path emits a tmux +command with the target omitted, and tmux's rule cannot run at all. +This is one rule by construction rather than two implementations that +currently agree. + +tmux ids sort NUMERICALLY here. After `$0`..`$8` are gone, a string +sort calls `$10` the oldest of `$9`, `$10`, `$11` -- wrong, and only +past nine, which is where it would go unnoticed longest. + +`show_hooks` and `show_hook` now report `resolved_target`, matching +`show_option`. A stable default is still a guess, and an untargeted +call should say which object it answered about. + +Mapped by the QA instance, which measured the split tool by tool and +established that it was one boundary rather than a scattered defect. + +#### A caller's regex can no longer run forever + +`search_panes` and `wait_for_text` compiled a caller-supplied pattern +and ran it over pane content with no bound. Python's `re` backtracks, +has no step limit, and cannot be interrupted from another thread: +`(a+)+$` against ONE 121-character line did not finish in three +minutes. + +That broke two different promises: + +- `search_panes` is readonly-tier, so the pattern is reachable at the + lowest safety level. One in flight is survivable; sixteen exhausted + the worker pool and every tool stopped responding. +- `wait_for_text` documents a `timeout` and checks it between poll + iterations. A match that never returns never reaches the check, so a + 2-second wait ran for 30 and counting -- and the caller who defends + themselves by passing a small timeout is exactly the one it fails. + `stop` takes caller regex on the same terms as `patterns`. + +Neither a deadline nor a worker cap can help, because a thread inside +`re` cannot be reclaimed. So the bound is the pattern. A repeat that +can iterate freely is refused when its body can match one string more +than one way: a body of varying width (`(a+)+`, `(a{0,3})*`, +`(a?){20}`), or alternatives that can begin on the same character +(`(a|a)+`, `(a|ab)+`). Either is refused wherever it appears, including +inside a lookahead or a conditional. + +The screen is a model of catastrophic backtracking rather than a proof +of its absence, and it is deliberately coarser than the engine: +`(a?)*` is refused even though CPython finishes it, because a screen +that leaned on that would also have to know when it stops applying. + +Ordinary patterns are unaffected, which is the half that matters: +`(cat|dog)+`, `(\d{2}){3}`, `(\d{2}){20}`, `^\[(INFO|WARN|ERROR)\]`, +`https?://\S+`, `a+b+c+$`, `\s{0,20}X` and `(?=.*ERROR)^\[` all still +compile -- a fixed-width body has only one way to split. A literal +(`regex=false`) is escaped and never screened. + +Reported by the QA instance, which also established that the tools keep +their contracts under adversarial input everywhere else -- `run_command` +holds its timeout even when zsh's line editor eats the command. + +#### `enter_copy_mode(scroll_up=...)` can no longer wedge the tmux server + +The repeat count went to tmux unbounded, and `window_copy_cmd_scroll_up` +runs `for (; np != 0; np--)` with no reference to how much scrollback +exists -- inside the single-threaded server. On a pane with NO history, +where every iteration after the first is a no-op that still costs full +price: + + scroll_up 1,000 -> 0.07s + scroll_up 100,000 -> 3.5s + scroll_up 10,000,000 -> still spinning at 30s + +The caller is not the one who pays. Three probe servers abandoned at a +40-second CLIENT timeout were still burning CPU when reaped later, at +422s, 289s and 159s, and `kill-server` on the same socket never got +through either -- so a timeout on our side cannot help and the bound +has to be applied before dispatch. + +The count is now clamped where `-N` is emitted, so any future caller +inherits the bound. Clamping does not change the outcome: on a pane +with 192 rows of history, `scroll_up=5` lands at 5, `50` at 50, and +`1_000_000_000` at 192 -- where the unclamped call also ended up, after +spinning for the other 999,999,808 iterations. + +A rejected negative `scroll_up` also used to leave the pane in copy +mode before raising. It is validated before entering now. + +Reported by the QA instance, which hit it twice from different +directions during a mechanical parameter sweep. + +#### A split that destroys its own pane no longer reports success + +`split_window(shell=...)` returned a `PaneInfo` for a pane that no +longer existed. tmux reports the split as successful, the process +exits, and the pane is removed with it: + + shell='/no/such/shell' -> PaneInfo(pane_id='%7') %7 does not exist + shell='#{session_name}' -> PaneInfo(pane_id='%8') %8 does not exist + shell='-k' -> success, running the DEFAULT shell + +Two mechanisms are needed, because tmux passes a one-argument command +to `$SHELL -c` rather than exec'ing it. A bare program that is not +executable is decidable in advance and is now refused. Shell syntax is +not: `#{session_name}` reaches sh as a comment, exits 0, and can only +be caught after the fact -- so the pane is confirmed to exist before it +is returned. + +The same pre-flight guards `respawn_pane`, where it now refuses far +less. Checking `shlex.split(shell)[0]` against PATH rejected +`cd /tmp && sleep 60`, `VAR=1 sleep 60` and `exec sleep 60` -- all +three run -- while asserting the pane would die. Anything sh +interprets is left to tmux. + +#### A start directory tmux cannot use is refused instead of ignored + +`create_session`, `create_window`, `split_window` and `respawn_pane` +all accepted a `start_directory` that could not be honoured, reported +success, and started the pane elsewhere: + + '/no/such/dir/xyz' ok pane started in $HOME + '-k' ok pane started in $HOME + '~/' ok pane started in $HOME, not the path + '' ok pane started in the MCP server's own cwd + +tmux never errors here: `spawn.c` tries the requested directory, then +`$HOME`, then `/`. An agent that splits with a typo'd project path runs +every later command somewhere else, and nothing in the result says so. + +An empty string is refused rather than treated as absent, because the +two differ: omitting the argument inherits, and `''` hands the caller +the server process's working directory. + +#### A taller pane is no longer reported as missed output + +`capture_since` treats history shrinking as proof the anchor was +trimmed. A resize-grow shrinks it for the opposite reason -- rows move +back onto the visible screen -- and one guard tells the two apart. +Nothing covered it: deleting the guard left all eighteen +`capture_since` tests green while turning every taller pane into +`lines_missed=true` plus a replay of scrollback the caller had already +read. + +#### A live server that will not answer is no longer reported as empty + +The resource path returned `[]` for a tmux server that exists, holds +sessions, and cannot be queried. That is the wrong conclusion rather +than a missing one, and it is the exact defect the tool path was fixed +for -- the fix had never reached the resources, so the two surfaces +disagreed about the same server: + + RESOURCE tmux://sessions -> [] + TOOL list_sessions -> "tmux server exists but could not be + queried: server exited unexpectedly" + +Reproducing it needs a live-but-unreachable server, which a nonexistent +socket cannot produce -- an old tmux client against a newer server does +it. That is why the surface had been checked and the case had not. + +An ABSENT server still returns an empty list, because there it is the +true answer. + +**A live session was reported as not existing.** The same empty +enumeration reached the RESOLVER, which turned "not in the list" into +"does not exist" -- so against a server that could not be queried: + + get_session_info / list_windows / list_panes / show_option + rename_session -> "Could not find session_name=live" + +An empty list is ambiguous and could honestly mean nothing is there. A +"not found" is a positive assertion about a session that was running. +The worst is `rename_session`: an agent told the session is gone may +recreate it under the same name while work continues in the original. + +It splits by resolver, which made it one fix rather than eleven. +Everything keyed on a session NAME went through the session resolver +and got the false negative; everything keyed on `pane_id` or +`window_id` surfaced tmux's own error -- untidy, and the only ones not +lying. The resolver now rules out "could not ask" before concluding +"is not there", and a genuinely missing session on a reachable server +still reports not-found, as does an absent socket. + +All six templates carry the check, not just the listing that surfaced +it. The other five resolve a specific object, so an unreachable server +made them answer "Could not find session_name=alpha" for a session that +exists -- the same wrong conclusion in different words, and one of them +leaked a raw `LibTmuxException` instead. Audited rather than assumed: +five of the six were missing it. + +Separately, a session name containing `/` is documented. tmux permits +`a/b`, and `tmux://sessions/a/b` does not match the template at all, so +it is rejected as "Unknown resource" -- which reads as "no such +endpoint" rather than "encode the name". `tmux://sessions/a%2Fb` +reaches it. Same class as the pane-id encoding above, failing one layer +earlier where the message cannot be improved from here. + +#### The kill tools say what went with the target + +Killing the last child takes its parent, and the results did not +mention it: + +| call | destroyed | reported | +|---|---|---| +| `kill_window` on a session's last window | the session | `Window killed: @0` | +| `kill_pane` on a window's last pane | the window | `Pane killed: %2` | +| `kill_session` on the only session | **the tmux server** | `Session killed: only` | + +The destructive tier permits all three; the messages understated them. +An agent tidying up a window has no reason to expect the session to go +with it, and killing one session on a single-session server exits tmux +entirely -- taking every other client and pane on that socket. + +Each now names what it observed disappearing, checked after the kill +rather than predicted from a count. A kill with no cascade is unchanged. + +These are the tools neither instance in this round's pairing had ever +invoked -- not because the tier hid them, which was checked and is +false, but because neither of us found them interesting. + +#### `snapshot_pane` reports which session and window the pane is in + +The server instructions recommend `snapshot_pane` over `capture_pane` + +`get_pane_info`. That substitution was lossy: `session_id`, +`window_id`, `pane_index` and `pane_active` existed only on the +latter, so an agent following the recommendation could not learn where +a pane is. + +That contradicted the other thing this branch shipped. `break_pane` can +move a pane to a different SESSION, the docstring now warns about it, +and the natural way to check afterwards is a snapshot -- which could +not answer. + +The parse was positional, so adding four format variables shifted every +field below them: `session_id` read back the pane index, `window_id` +read the session, and so on for every existing field. Caught by +checking against raw tmux rather than against the model. Fields are +keyed by NAME now, so an inserted variable cannot shift anything, and +the test compares each one against `display-message` output. + +`pane_title` is exposed here as `title`. Same tmux format, two names +across two models; left alone because renaming is a breaking model +change, and the test states the mapping rather than leaving it as a +trap. + +#### The audit log redacted a secret going in and logged it coming out + +`set_environment(value=...)` is digested. The natural next step -- +searching the pane to check the credential did not leak -- wrote it to +the audit log verbatim: + + ok set_environment value={'len': 30, 'digest': 'bc79fc24b1e3'} + LEAKED search_panes pattern='AKIAIOSFODNN7EXAMPLE_LEAKCHECK' + LEAKED wait_for_text patterns=['AKIAIOSFODNN7EXAMPLE_LEAKCHECK'] + +The delivery was protected and the verification was not, which is the +wrong half: checking whether a secret reached somewhere is exactly what +a careful caller does, and `wait_for_text`'s own docs steer callers +toward waiting on markers. + +`pattern`, `patterns` and `stop` are now redacted. Their whole purpose +is to carry a value the caller is looking FOR, and the realistic reason +to look for a credential is that one may have leaked. They needed list +handling as well as naming -- `patterns` is a list, and naming it +without that would have reproduced the same bug one layer down. The +digest still correlates with the one from `set_environment`, so an +auditor can tell the same value was searched for without being shown +it. + +The allowlist defaults to logging verbatim, so a free-text argument +added later is exposed by omission rather than by judgement -- +`break_pane.window_name` landed earlier in this branch that way. The +arguments deliberately left verbatim are now listed as such beside it: +a path is legitimate audit content, and knowing where pane output went +is often the point of the record. + +#### A truncation cap below 1 produced impossible numbers + +`capture_pane(max_lines=0)` returned MORE rows than no truncation at +all -- eleven from a ten-line pane -- while its header announced that +all ten had been dropped. `max_lines=-100` claimed 110 lines were +truncated from a pane holding ten. + +Python slices a non-positive cap into nonsense rather than failing: +`lines[-0:]` is the whole list, and the dropped count `len - max_lines` +grows when `max_lines` is negative. Neither raised, so both answers +looked like data. + +The severity is not a crash, it is that the header is the ONLY +disclosure channel this tool has -- it returns a bare `str`, with no +`truncated_lines` field to cross-check as `capture_since` and +`snapshot_pane` have. A caller parsing it to decide whether to +re-request with a bigger budget got a number that could not be true. + +Now refused with the wording `search_panes` already uses for the same +shape. The guard sits in the helper the four truncating tools share, so +`run_command`, `snapshot_pane` and `show_buffer` are covered by +construction rather than one at a time. + +#### `break_pane` and `join_pane` + +Moving a pane between windows had no tool, so the only route was to +kill it and start again somewhere else -- which loses the process, the +scrollback, and the pane id, along with any `capture_since` cursor a +caller holds against that id. + +**A `mutating`-tier client could destroy sessions, and take the server +down.** All four `kill_*` tools are hidden at that tier, and +`join_pane` reached the same end state with no check: moving the only +pane of a session's only window into another session left the source +with no windows, and tmux destroyed it. On a single-session server that +is the server. + +`break_pane` already refused exactly that predicate. The line was drawn +correctly and implemented on one side of it -- a window emptying is +inherent to moving its last pane and is disclosed; a session emptying +is avoidable, so it is refused. `join_pane` now refuses it too, and +only when the destination is a DIFFERENT session, since a move within +one cannot empty it. + +`move_window` had the same defect and was found by auditing which +`mutating` tools can reduce an object count, rather than by tripping +over it: moving a session's last window elsewhere destroyed that +session and the result named only the destination. Also refused. + +The audit covered all 33 tools at that tier. `select_layout`, +`resize_window`, `clear_pane` and `set_pane_title` leave every count +unchanged. Two routes remain open by construction and are recorded +rather than guarded: `send_keys` and `run_command` can type `exit`, +which is inherent to sending input, and `respawn_pane` with a command +that exits immediately kills the pane -- and its window and session if +it was the last. `respawn_pane` already refuses and names that cascade, +so the consequence is disclosed; closing it would mean either refusing +every respawn on a single-pane server or predicting that a command will +exit, which tmux only reveals afterwards. Whether that tool belongs at +`destructive` is a decision about what the tier permits. + +`join_pane` reports whether the move DESTROYED the source window. +tmux removes a window left with no panes, so consolidating panes +deletes windows the caller never named -- measured, moving the last +pane out of `@9` removed it and the result named only the destination. +It returns `source_window_id` and `source_window_destroyed`, observed +after the move rather than predicted from a pane count. Unlike +`break_pane`'s session case this is not refused: a window emptying is +inherent to moving its last pane, so refusing it would forbid moving +most panes. + +`PaneInfo` gained `session_name`. It carried `session_id` and no name, +so `break_pane`'s warning to check which session a pane ended up in +could not be followed on `join_pane` -- which crosses sessions the same +way -- or on `list_panes`. + +Both report where the pane actually ended up, re-read after the move +rather than assumed from the request: `break_pane` returns the window +the pane now lives in, and `join_pane` returns the pane with the +`window_id` it actually landed in. + +`break_pane` refuses when the pane is the last one in its session's +last window. tmux puts the new window in the CURRENT session, so in +that case the source session is left with no windows and tmux +**destroys it** -- measured, breaking `alpha`'s only pane moved it to +`beta` and `alpha` ceased to exist, while the result reported only +where the pane went. Destroying a session is destructive-tier work and +this tool is `mutating`, so it refuses rather than discloses. Found by +running tool sequences rather than single calls: the failure surfaced +as a later `new_window` reporting "can't find session". + +`break_pane`'s docstring warns that the new window may land in a +different SESSION. tmux puts it in the current session, and "current" +is the most recently active one rather than the pane's own, so breaking +a pane out of session A can move it to session B. The result carries +the session it went to and matches reality, so this is disclosure +rather than a defect -- but it is the same tmux rule as the untargeted +`show_option` resolution with a much larger blast radius. A wrong READ +can be re-asked; a MUTATION that relocated a pane across sessions has +already happened. + +#### Four results now say what tmux actually did + +Each of these returned something true-but-uninformative, in a way that +left the caller unable to check the thing they most needed to. + +**`set_option` echoed the caller's spelling.** tmux accepts an +unambiguous prefix, so `set_option(option="history-lim")` sets +`history-limit` and reported `history-lim` -- and that same field is +the only evidence a caller gets that the option they named is the one +that changed. `resolved_option` now carries what tmux resolved it to. + +**An untargeted `show_option` did not say which session answered.** tmux +resolves it through a "current" session chosen from attached clients, +and an MCP client has none, so `scope_queried="session"` named a scope +without naming the object. It is a real and stable choice, not an +arbitrary one -- `resolved_target` reports it. + +**`unset_environment` could not tell a removal from a no-op.** tmux +exits 0 whether or not the variable existed, so afterwards the two are +indistinguishable. The variable is read first; `status` is now `unset` +or `absent`. + +**`show_environment` did not report its scope**, where `show_option` +already did. `scope_queried` is `global` or `session`. + +#### `list_clients` + +Nothing could answer "who is attached to this server". Two behaviours +depend on client state and neither could report it: `is_caller` +compares against the caller's own client, and an untargeted +`show_option` resolves through tmux's current session, which is chosen +from the attached clients. Verifying either meant leaving the server +and running `tmux list-clients` by hand. + +Reads tmux directly rather than through libtmux's `Server.clients`, +which leaves `client_tty` and `client_pid` unset -- the same family of +unpopulated `client_*` attributes that makes them useless as filter +fields. An empty list means nobody is attached, which is the normal +state for an agent-driven server, and is distinguished from a server +that cannot be reached at all. + +#### A slow shell is no longer mistaken for a wedged one + +The started-channel grace refused a pane whose prompt hook takes longer +than the grace -- and the command then ran, so the refusal said "it has +not run" about something that had. That is the double execution the +guard exists to prevent, arriving on an ordinary call rather than on a +wedged pane. Measured: a `preexec` sleeping 8 s was refused at a 10 s +timeout while its command completed. + +Scaling the grace off the caller's budget narrowed that window without +closing it: any shell slower than the grace and faster than the timeout +still lost. So the grace expiring is no longer the whole decision. A +foreground process that CHANGED since the payload was sent is positive +evidence that a shell read the line and is working, and the wait is +extended instead. + +Partial by construction, and it fails in the safe direction. Slow work +in a shell BUILTIN spawns no child and still reads as unchanged, so it +is refused exactly as before -- the check can only remove an +over-refusal, never create a false accept. Measured after: an 8 s +prompt hook completes and reports `exit_status=0`, while a Python REPL, +a `cat`, and a shell in `read` are all still refused at the grace. + +#### `pipe_pane` does what the redirect does, instead of predicting it + +Four more destinations passed the pre-flight, returned "Piping pane %N +to ...", and captured nothing: an existing DIRECTORY, a DANGLING +SYMLINK pointing into an unwritable directory, `/dev/full`, and a +300-character basename. The check tested the parent directory and +`os.access`, which is a proxy for "a shell can append here" -- and each +time the proxy was wrong it was wrong in a new way, which is the +argument against adding a fifth stat check. + +Opening the path for append answers the real question and closes all +four at once: the directory raises `IsADirectoryError`, the dangling +link resolves and fails on its real parent rather than on the directory +holding the link, `/dev/full` is not a regular file, and the long name +raises `ENAMETOOLONG`. + +The regular-file test stays separate because `open` succeeds on a FIFO +that has a reader and on a character device. A reader-less FIFO blocks +the shell inside `open()` indefinitely, so the pipe looks perfectly +healthy -- `#{pane_pipe}` reads 1 at every delay measured -- while +nothing is ever captured. No poll of any duration can see that, which +is also why this is a pre-flight rather than a check after the fact: +the answer is available synchronously, and polling would spend latency +on every call to be less correct. + +It says nothing about the destination AFTER the pipe starts, and the +docstring now says so, because "it actually opens the file" reads like +a stronger guarantee than it is. Delete the file, remove its directory, +or replace it with a directory mid-capture: the writer keeps its fd on +the unlinked inode, so the capture grows in `/proc//fd` while the +path is gone and `#{pane_pipe}` correctly reads 1 throughout. Log +rotation is the realistic way to hit it. The polled check considered +here would not have caught any of those three either. + +#### Hook queries answer about the object you named + +`show_hooks(scope="window", target="@0")` returned the SESSION's hooks, +and the window's were unreachable at any scope. Same for `scope="pane"`. +A caller asking "what hooks are on this window" was told about a +different object, with nothing indicating the answer came from +elsewhere. + +`_resolve_hook_target` resolved the object and then dropped the scope, +because passing a redundant scope to a Session mis-builds libtmux's +argv. That premise holds for Session, whose object default IS the +session tree -- measured, `Session.show_hooks(scope=Session)` returns +an empty listing. It does not hold for Window or Pane: their default +resolves to the session tree too, so dropping the scope redirected the +query one level up. They take the explicit scope without the argv +problem, so the reset now applies to Session alone. + +The untargeted listing was incoherent for a related reason. It merged +the GLOBAL-window tree onto a session-scope base, producing a set that +corresponds to no tmux scope -- session hooks plus global-window hooks, +with global-session hooks missing entirely -- and it listed hooks that +`show_hook(name)` with the same defaults could not find. + +Which window tree is the counterpart depends on the base query, and +treating `scope=None` and `scope="server"` as interchangeable is what +broke it: `"server"` queries the global session tree, so the global +window tree pairs with it; `None` queries this session's tree, so this +window's does. `show_hooks()` and `show_hook(name)` now agree at every +scope, which is the invariant the merge existed to protect. + +An explicit `global_=True` had the same shape: it made the base query +global while the merge still fetched the CURRENT window's tree, so +`show_hooks(global_=True)` returned a real global-session hook stapled +to a non-global window hook, and omitted the global-window hook that +belonged there. The two ways of asking for "the globals" now agree. + +One difference is tmux's own and is documented rather than removed: +`show_hooks` ENUMERATES the hooks set at a scope, while +`show_hook(name)` RESOLVES with inheritance, so the latter can answer +with a session hook when asked at window or pane scope while the former +reports nothing there. + +#### `wait_for_text` sees a status line printed again + +A program that prints `BUILD OK`, scrolls, and prints `BUILD OK` again +could never be waited on. The second occurrence was suppressed and the +wait ran to its ceiling reporting `found=false` -- against a pattern the +program had just printed, fresh, inside the wait. That is the tool's +headline use case with the sign flipped: a test runner printing `PASS` +per file, a dev server printing `Compiled successfully` on every +rebuild, a poller printing `waiting...`. + +The entry snapshot resolves one row's worth of ambiguity -- the entry +cursor row, which the anchor deliberately includes so a daemon's single +`ready` line stays matchable -- and it was doing so with a `frozenset` +of every row below the cursor. Flattening discards position, so a line +twenty rows down permanently blocked an identical line arriving on the +cursor row. + +Rows are now compared against what THAT index held at entry. Measured: +the identical reprint matches 1.96 s in (when it is printed), a near +miss `BUILD OK 2` still matches, and a pane where the text is never +reprinted still does not match, reporting `matched_at_entry=true`. + +Not a complete fix and the code says so: a row that rewrites the same +text at the same index still reads as unchanged. tmux exposes no +per-row last-written time, so something content-shaped is unavoidable +at the bottom. The hole shrinks from "any line anywhere below the +cursor" to "the exact row that already held that exact text". + +#### `run_command` answers a wedged pane in seconds, not at the timeout + +The refusal above was correct and arrived at `timeout + 1s`, so a call +against a Python REPL at the default 30 s timeout took **31 seconds** to +report something knowable in milliseconds -- fifty times slower than the +success path, for an answer that does not get truer by waiting. + +The started channel is now waited on with its own short grace before +the completion channel takes the remaining budget: two bounded +`wait-for` calls, still no polling and still no extra shell round trip. +Measured: refusal is capped near 3 s at every caller timeout from 2 s to +30 s, an ordinary call is unchanged at 774 ms, a nested `bash` at 47 ms, +and a genuine long-running command on a pane at a prompt still reports +`timed_out` with `command_may_still_run`. + +Cancellation reaps whichever phase is in flight -- verified directly on +a pane that never starts, where the started channel's child appears and +is gone after the cancel. + +The orphan test needed two fixes and the second is the interesting one. +Its probe knew only the completion channel's `r_` prefix, so it was +blind to a phase this branch added; it matches both now. But it still +failed, because the probe walks every entry in `/proc` SYNCHRONOUSLY +inside the event loop it is polling. On a busy box that starves the +`run_command` it is waiting for, so the poll prevents the child it is +polling for from ever being spawned -- a measurement that destroys what +it measures. It runs off the loop now, as does its `wait_for_channel` +sibling, which had the same blocking probe. + +A caller whose own budget is at or below the grace gets a plain +timeout rather than a refusal: there, "it has not started yet" and "it +never will" are the same observation. + +The grace is a fraction of the caller's budget (half, floored at 5 s) +rather than a fixed constant, and the reason is a defect the parallel +test gate caught in the first version of this fix. A fixed 3 s refused +a LEGITIMATE command at loadavg 31, because a configured zsh under that +load takes longer than 3 s to answer. Telling a caller its command did +not run, when it is merely slow and about to, is the same +double-execution hazard the started channel exists to prevent, pointed +the other way -- so over-refusal is the dangerous direction and the +grace has to be derived from the caller's own statement of how long the +work may honestly take, not from a guess about machine speed. + +#### The `send_keys` Unicode warning blamed the wrong component + +It read "tmux renders combining marks and zero-width joiners as +`` placeholders". The advice built on it -- do not string-compare +captured text against what you sent -- is right and worth keeping. The +attribution was wrong, and tmux's source settles it: there is no +hex-in-angle-brackets format anywhere in it. + +The transform is the pane's LINE EDITOR. Same payload, same tmux, +different occupant: + +| pane running | result | +|---|---| +| `sh` | verbatim | +| `cat` | verbatim | +| `zsh -f` | `e<0301>`, `a<200d>b` | + +zsh renders a character it considers unprintable as `` when it +echoes typed input. Precomposed U+00E9 survives and decomposed +`e` + U+0301 does not, so `école` round-trips one way and not the +other -- which is why a sweep across nine tmux versions and three +locales reported the whole thing unreproducible, and why testing more +tmux versions could never have settled it. + +#### Four smaller honesty and validation fixes + +**`clear_pane` says what it destroys.** It clears scrollback, not just +the visible screen -- measured, `history_size` 132 to 0 with the prior +content unreachable through `capture-pane -S -300` afterwards. The +docstring led with "Clear the contents of a tmux pane" and alluded to +scrollback only sideways, so an agent reaching for "clear the screen +before I run this" took the user's terminal history with it, with no +undo, at the `mutating` tier. + +**A server named through `extra_socket_paths` now reports its name.** +Those rows came back with `socket_name: null` while every scanned row +carried both name and path, so a caller filtering the same list by name +could not see them. Same completeness rule the scan already follows. + +**`enter_copy_mode` rejects a negative `scroll_up`.** It was accepted +and silently did nothing; `search_panes` already refuses `offset=-1` +with a precise message. + +**Two latency facts are now in the docstrings that would send an agent +the wrong way.** `call_readonly_tools_batch` is not a speed win below +three or four operations -- measured 2.5x slower at one, break-even +around three -- because batching saves the transport round trip and +re-pays the per-call framework cost. And `run_command` runs in the +pane's INTERACTIVE shell, so it pays that shell's per-command hooks: +914 ms against a configured zsh whose prompt runs `git status`, versus +64 ms against `sh`. Roughly 615 ms of that is the shell, not the tool, +and anyone benchmarking it would otherwise conclude the tool is slow. + +#### `run_command` stops claiming a command "may still run" when it never ran + +A pane whose shell is mid-continuation swallows the wrapper. Every +signal the busy-pane guard reads says "shell at a prompt" -- +`pane_current_command` is the shell, `alternate_on` is 0 -- and no +deny-list entry can fix that, because the occupant genuinely *is* the +shell. + +The worst shape was `read`. A shell sitting in `read ANSWERVAR` +consumed the wrapper's first line as its answer and then **ran the +command** from the following lines, while the status line died on a +parse error. The result said `timed_out`, `command_may_still_run` -- +failure-shaped and false. An agent retrying a non-idempotent command +ran it twice. + +Two changes, and neither costs a round trip: + +The wrapper is now a single line whenever the command allows it. +Atomicity is what makes the answer trustworthy: `read` consumes a whole +line as its answer, so one line is eaten entire and nothing executes. +Split across lines, the same shell eats only the first and then runs +the rest -- measured. + +The wrapper signals a private "started" channel before running the +command. tmux latches a `wait-for -S` that has no waiter, so asking +about it afterwards is a question about the past, not a second wait -- +measured at 4 ms against 2 s for a channel nobody signalled. The check +therefore sits on the timeout path and adds nothing to the budget. A +pre-flight probe was the obvious alternative and was rejected on cost: +it is a full shell round trip, about 628 ms on a configured zsh, and +would have nearly doubled every call. + +Measured across the occupants: an unclosed quote, an unclosed function +body, `read`, a Python REPL, `cat`, and a pane busy with `sleep` are +all now reported as not run, with the command verifiably never +executing. An ordinary call is unchanged at about 700 ms on this box, +of which roughly 615 ms is the pane shell's own per-command hooks. + +The refusal deliberately does not guess between "nothing ran and +nothing will" and "your input is queued behind a running command". Both +look identical from outside and they call for opposite reactions, so +the message names the foreground process and states both readings. + +Multi-line commands are now refused outright, whatever +`suppress_history` says. They were the one path the wrapper could not +send atomically, and leaving them meant shipping two code paths where +only one was sound -- measured, a shell mid-`read` ate the split +wrapper's first line, RAN the rest, and the tool then reported "it has +not run" and "in a `read` ... it never will" about a command that just +had. That is worse than the bug it replaced: the old timeout at least +left a caller uncertain, while a confident sentence talks them into the +retry, and a non-idempotent command executes twice. + +Join with `'; '`, or use `send_keys` / `paste_text` for raw multi-line +input. The error says so. + +#### `list_servers` is 6.7x faster + +`list_servers` probed each live socket with its own tmux subprocess, +serially. It is strictly linear in live servers -- about 12 ms each -- +so a box with 119 of them spent **1.5 seconds** in the tool the server +instructions point at for discovery, which makes it the first call a +client makes. Every other read-only tool measures 12-58 ms. + +The probes are independent, so they now run in a pool of 16. +`ThreadPoolExecutor.map` preserves input order, so the listing stays +sorted by socket name however the probes interleave. + +That alone only bought 2x, and the reason was somewhere else entirely. +`_get_server` held the module's cache lock across `Server.is_alive()` +-- a tmux subprocess round trip -- so a thread that missed the cache +held the lock for the length of a subprocess. That is exactly the +scan's own pool: sixteen threads, sixteen distinct socket names, +sixteen cache misses. The liveness check now happens outside the lock, +and two threads racing to cache the same key agree via `setdefault`. + +It does NOT generalise beyond multi-socket work, and an earlier draft +of this entry claimed it did. Measured against that claim: sixteen +concurrent tool calls on one socket went 139.2 to 127.7 ms (noise), and +on sixteen distinct sockets, cold, 122.1 to 119.5 ms. Concurrent tool +calls were already parallelising about 3x before the change, because +they arrive through asyncio and the common single-socket case hits a +warm cache where the lock is a brief dict lookup. A batch or a +multi-agent workload should not expect a speedup from this. + +Measured at 40 live servers, same box: 536 ms serial, 176 ms at 4 +workers, 80 ms at 16, 76 ms at 32. 16 takes the win and 32 buys +nothing. Stale entries were never the problem and still are not: 200 +extra non-socket files cost about 8 ms total. + +#### `tmux://panes/{pane_id}` works past pane 9 + +A tmux pane id starts with `%`, and the URI layer percent-decodes every +captured template parameter before a handler sees it, so `%10` arrived +as byte `0x10` and matched nothing. `%0` through `%9` survived only by +accident -- one trailing hex digit is an *invalid* escape and passes +through unchanged. So the resource surface worked right up until a +server created its eleventh pane, while the tool surface answered +correctly for the same pane. An agent got different answers depending +on which surface it used. + +The obvious repair does not work and is worth recording so nobody +reaches for it: re-encoding the decoded byte back to `%XX` handles +`%10`-`%7f` and cannot handle `%80`-`%99`, which decode to bytes that +are not valid UTF-8 and arrive as U+FFFD with the digits already gone. +That fix passes every test written against low pane numbers and fails +once a server reaches pane 128. + +A bare number needs no escaping at any pane number, so `tmux://panes/10` +is now the spelling that always works; `%2510` keeps working for anyone +who found that workaround. The not-found error also `repr()`s the id +and says so when it is unprintable -- previously it read +`"Pane not found: "` with an invisible control character, pointing at +nothing. + +#### `capture_since` no longer re-delivers scrollback when a pane narrows + +Narrowing a pane rewraps its history longer, and the cursor carried no +width, so the anchor's row coordinates silently stopped meaning +anything. `start` went negative by exactly the rewrap growth and +`capture-pane -S` returned that many rows of already-seen scrollback as +new output -- under `lines_missed=false`, asserting nothing had been +missed. Measured: 160 to 40 columns re-delivered 189 stale lines, and +**one** column of narrowing re-delivered three. + +Widening was already caught, which is what hid it: rewrap makes history +*shorter*, so the existing shrink branch fired. Growth from a rewrap is +indistinguishable from growth from real output, so no branch fired on +the way down. The cursor now carries `pane_width` and a change +invalidates it exactly as a `pane_pid` change does -- width being the +precise discriminator, since history growing from real output must stay +valid. A cursor minted before this field existed is treated as +invalidated rather than trusted, which self-heals on the next call. + +The trigger is a user resizing their terminal. + +A THIRD cause of the same silence: a screen reset. `clear_pane` on a +pane with no scrollback yet -- `history_size` 0, `cursor_y` 2 -- leaves +`history_size` 0 and `cursor_y` 0, so the anchor points BELOW the new +output and the call returns nothing under `lines_missed=false`. Every +other check assumed an anchor dies by history shrinking or by passing +the bottom row; a reset moves the cursor UP without touching history. +`clear_pane`'s own docstring recommends the sequence that hits it -- +clear, then observe. + +The discriminator is that the cursor moved BACKWARD while history did +not grow. The conjunction is what makes it safe: ordinary output only +moves the cursor down, scrolling holds it at the bottom row and grows +history, and a resize-grow shrinks history while the cursor moves down. +Verified that a 60-line scrolling burst is still not invalidated. + +Width is not merely *a* discriminator, it is the only correct one, and +tmux's source says why: `screen.c` calls `grid_reflow()` on a width +change, `grid_reflow()` splits any line wider than the new width and +grows the grid's line count, and `#{history_size}` is that line count. +So "history_size went up" has two causes -- new output, which must keep +a cursor valid, and reflow, which invalidates every absolute row +coordinate -- and width is the only field that separates them. + +#### `run_command` refuses a pane in copy mode + +Copy, view and clock mode own the keyboard while `alternate_on` stays 0 +and `pane_current_command` still reads as the shell, so both existing +arms of the busy-pane guard missed it. Measured with a client attached: +across eight trials the command never ran, the payload was consumed as +copy-mode keystrokes, and the user's scroll position was destroyed in +seven of them -- while the result reported `command_may_still_run`, +which would send an agent away from retrying a command that had never +happened. + +`#{pane_in_mode}` rides in the `display-message` round trip the guard +already makes. Refusing before sending is what preserves the scroll +position; a probe would still have typed into it. + +All four refusal branches were then instrumented to confirm none is +dead -- a guard branch that never fires is indistinguishable from one +that works: + +| occupant | branch | latency | +|---|---|---| +| `less`, `vi`, `man`, `nano` | alternate screen | 26-353 ms | +| `top`, `more` | deny-list | 19-26 ms | +| copy mode | `pane_in_mode` | 17 ms | +| `cat`, `python3`, `read`, unclosed quote, `sleep` | started channel | ~5000 ms | + +That also settles what the deny-list is worth. `top` and `more` both +hold `alternate_on=0`, so only the deny-list catches them; without it +they fall through to the started channel and cost ~5000 ms instead of +~20 ms. Each cheap layer is roughly 250x on its own cases. + +Without an attached client the same call fails benignly with "no +current client" and copy mode survives, so a test written on a detached +fixture sees the mild version and misses this one. + +#### A caller string starting with `-` no longer runs a different command + +`set_environment(name="-u", value="VICTIM")` returned +`{"name": "-u", "value": "VICTIM", "status": "set"}`. What actually ran +was `tmux set-environment -u VICTIM`, which **deleted** a pre-existing +variable. Nothing was set, something was destroyed, and the result said +the opposite. + +`set_option` had it twice, and the second is worse because two +mechanisms stack. `set_option(option="-g", value="x")` let tmux eat +`-g` as the global flag, leaving `x` as the option name -- which tmux +then prefix-matched to `xterm-keys` and turned off. A caller who named +neither `xterm-keys` nor anything resembling it silently changed it and +was told `status="set"` for the option they did name. + +tmux parses flags before quoting can protect anything, and libtmux +emits `[name, value]` with no `--` terminator. Quoting itself is sound: +`A;kill-server` and `CANARY_TWO -u` were both stored verbatim as absurd +but literal names, killing nothing and unsetting nothing. The hole is +specifically a *first character* of `-` that happens to name a flag the +target command accepts -- `set_option(option="-U")` was already refused +by tmux as an invalid flag, which is what makes `-u` and `-g` the +dangerous cases rather than the obvious ones. + +Environment names are now validated against POSIX +`[A-Za-z_][A-Za-z0-9_]*`, which rejects every flag-shaped name by +construction along with the junk above; option names must not begin +with `-`, the looser rule that `@user-options` require. `display_message` +takes the third form: a format legitimately may start with `-`, so it +gets tmux's `--` terminator instead of a refusal. Previously +`format_string="-p"` was eaten as tmux's own print flag and tmux +answered with its DEFAULT message -- a plausible string answering a +question nobody asked. + +This is the `send_keys` flag bug, fixed earlier in this branch with +`--`, reappearing in three more tools. There it dropped a keystroke; +here it destroys state. + +#### `unset_environment` + +There was no way to remove a tmux environment variable. `value=""` sets +an empty string, which new panes still inherit as set-but-empty, and +`EnvironmentResult.removed` described a state no tool in the server +could produce. The only route to an unset was the flag bug above, so +closing that without this would have removed a capability rather than +a hazard. + +#### Filters answer, or say why not -- three silent-empty paths closed + +Follow-ups on the filtering work above, all found by driving the server +as a client rather than by reading the code. + +**Operators that cannot apply to a boolean are refused.** `is_caller__in` +and `is_caller__nin` both returned zero rows against the same set -- +every row is either in or not in, so one of those answers was false +whichever way you read it -- and `is_caller__regex: ".*"` matched +nothing. Upstream, `lookup_in`, `lookup_nin` and `lookup_regex` all +guard on `isinstance(data, (str, list))` and fall off the end to +`return False` for a bool. That is not fixable from here, so the +operator is now checked against the field's declared type and refused +with a message instead of run. + +**An unparseable boolean is refused.** `is_caller: "ture"` compared a +string to `True`, matched nothing and reported no error -- the typo +bug this branch fixed for field names, one position to the right. The +accepted tokens are in the message. + +**A traversal path with no trailing operator now works.** +`active_pane__pane_id` raised "Invalid filter operator 'pane_id'" +because the last segment was taken as an operator unconditionally. +libtmux gets this right (`OpNotFound` subclasses `ValueError`, so +`QueryList` catches its own raise and defaults to `exact`), which made +the server stricter than the engine beneath it -- and it rejected from +callers the exact key form its own alias generates internally. + +The fallback is not free: read naively, a mistyped operator like +`session_name__containss` reads as an attribute path, resolves on +nothing, and filters every row out silently -- reintroducing the bug +one line below the fix. A multi-segment path that no item can resolve +is therefore an error, which the operator message is folded into. + +#### `run_command` refuses a pane running `top` + +`top` owns the keyboard but repaints the primary screen, so +`alternate_on` stays 0 for as long as it runs -- measured at 0 through +8 seconds, not a startup race. It therefore passed both arms of the +busy-pane guard and took this tool's exit-status wrapper as single-key +commands. `htop` and `watch` reach the alternate screen and were +already refused, which is what made the gap easy to miss. + +#### Filter by what a listing actually showed you + +`list_panes(filters={"is_caller": true})` raised `Unknown filter field +'is_caller'`. That call is not an edge case: it is the workflow the +server's own instructions hand every client as the only way to answer +"which pane am I in?", since there is no whoami tool. + +Filters went straight into libtmux's `QueryList`, which resolves keys by +`getattr` on the tmux object. Two kinds of output field are invisible +there -- ones the server computes while serializing (`is_caller`) and +ones tmux exposes under a different name (`window_count`, `pane_count`, +`active_pane_id`). So the documented workflow could not work, and +neither could filtering a session list by the window count it had just +displayed. + +The old error message compounded it. "Call this tool without filters to +see available fields" pointed at a listing that shows 6 of a Session's +230 filterable fields, two of which were themselves rejected as filters +-- so following the advice returned a set that was both 97% incomplete +and partly invalid. + +Every field a tool returns is now filterable. Computed fields are +applied after serialization through the same `QueryList` operators, so +`__contains` and friends behave identically; aliased ones rewrite to +their attribute path. Because `filters` is typed `dict[str, str]`, +`"true"` is accepted wherever `true` is. The error now names the output +fields outright and counts the wider attribute set behind them. + +#### `respawn_pane` returns the new process, not the one it replaced + +`pane_pid` changes the instant `respawn-pane` returns, but +`pane_current_command` reports tmux's transient login shell for a few +tens of milliseconds before the requested command execs over it — +measured at roughly 50 ms on tmux 3.7c. The model was serialized from +the first read, so a caller checking "did my shell take effect?" against +the returned `PaneInfo` got the process that was about to be replaced. + +This was not cosmetic: `test_respawn_pane_replaces_shell` asserts on +that field and has been failing intermittently since it was written, +absorbed by the suite's `--reruns=2`. That existing test is the +regression guard, and it is now deterministic. + +Three plausible predicates were measured and all three fail. Waiting for +the pid to change is necessary but not sufficient — over 15 runs the +command was still stale at pid-change 15 times. Waiting for the command +to *change* never fires when a pane is respawned as itself. Waiting for +two consecutive equal reads is the worst of the three: the pre-change +value is itself stable for those ~14 ms, so a fast poll debounces onto +the OLD value and returns it confidently — measured at 0/6 stale with a +20 ms poll, 2/6 at 5 ms and 3/6 at 1 ms, i.e. correct only by accident +of the interval. + +What ships instead matches the requested command's basename, which is +interval-independent. Without a `shell` there is nothing to wait for, and that is structural: +tmux's `spawn.c` guards its default-command fallback on +`sc->argc == 0 && (~sc->flags & SPAWN_RESPAWN)`, so a commandless +respawn skips it and reuses the pane's existing `argv`. The new command +equals the old for any pane, including one running an editor rather +than a shell. A wrapper command whose basename never appears falls +through to a 250 ms cap, ten times the measured 26.4 ms worst case. + +#### Three tools now report what happened, not what was asked + +`pipe_pane`, `respawn_pane` and `paste_text` each returned success for +an operation that did not do what the caller asked. In all three the +confirming signal was already reachable and simply not consulted. + +**`pipe_pane` to an unwritable path.** tmux hands the pipe command to a +shell and reports success whatever that shell then does, so a redirect +into a missing directory produced `Piping pane %N to ...` and no file +ever appeared — and a stale file already at that path would then read +back as if it were live capture. The destination is now checked before +piping. `#{pane_pipe}` looks like the obvious discriminator and is not: +measured, it reads `1` immediately after a doomed pipe, because the +shell has been spawned and has not yet failed on the redirect. + +**`respawn_pane` with a shell that cannot run.** tmux does not fail a +respawn whose command cannot be executed: the new process dies at once +and takes the pane with it — and its window, session and server, if it +was the last one. Measured, a mistyped shell path destroyed the whole +server while the tool returned a `PaneInfo` describing a pane that no +longer existed. The program is now checked before respawning, since +catching it afterwards can only report the loss and even that races the +dying process. A respawn that still loses its pane raises rather than +returning stale state. + +**`paste_text` with a trailing newline.** Bracketed paste — the default +— holds the newline in the shell's edit buffer instead of submitting. +That is correct terminal behavior and a safe default, but `Text pasted` +reads as "your command ran", and the text is not inert: it executes +when Enter next reaches that pane from any source, possibly long after +the call and out of order with it. The result now says so and names +both ways to submit. + +#### Resource URIs warn that a name must be percent-encoded + +A resource URI's path segment is percent-decoded before lookup, which +is correct URI behavior — but nothing said so, and nothing round-trips +a name into a URI safely. With two sessions named `pct name` and +`pct%20name`, reading `tmux://sessions/pct%20name` returns **`pct +name`**: the wrong session, silently, with no error. Only +`tmux://sessions/pct%2520name` reaches the one whose name contains the +literal `%`. A space needs no encoding, so the trap only springs on +`%`. + +The resource docstrings now say this at the point an agent constructs a +URI. Concatenating a `session_name` straight from `list_sessions` is +the obvious construction and the one that breaks, so a ready-made +`uri` field on the session/window/pane models remains the better fix +and is recorded as follow-up rather than landed unverified. + +#### A missing resource is no longer an "Internal error" + +Reading `tmux://sessions/nosuchsession` answered `Internal error: +Session not found: nosuchsession`. That is a caller naming something +that does not exist, reported as a server fault. + +`ToolErrorResultMiddleware` exists to remove exactly that wrapper — its +docstring says so — but it intercepted `tools/call` only, while +fastmcp's transform it inherits from serves *every* message kind. So +the defect survived one fork over, on resources. + +Fixed at the transform rather than by adding a resource hook: an error +this server raised deliberately to describe a caller-caused failure now +maps to `-32002` on a resource read and `-32602` elsewhere, with its own +message intact. The property — an expected failure is never an internal +error — now holds on every path that transform serves, instead of on +the one path that was noticed. + +#### A non-MCP buffer name is described accurately + +`load_buffer`/`paste_buffer`/`show_buffer` answered a name like +`yanked` or tmux's own `buffer0` with `Invalid buffer name` — but those +names are perfectly valid to tmux. What is true is that they were not +allocated by this server, which only touches buffers it created because +tmux buffers can hold OS clipboard history. + +The message now says that, and the suggestion states the consequence +plainly: a buffer created outside this server — a copy-mode yank, or +`buffer0` — is not reachable by design, and `load_buffer` is the way to +stage content it can read back. + +#### `run_command` checks the pane has a shell to talk to + +`run_command` assumed a cooperative shell sitting at a prompt, and +neither verified nor documented it. The assumption holds in tests and +breaks in a live session, in two ways. + +**A program owns the keyboard.** Sent to a pane running +`less`, this tool's exit-status wrapper was consumed as *less's* +keystrokes — `s=$?` became its save-to-file command and a fragment +escaped to a shell, leaving `Cannot write to "=$?; tmux ...` on screen. +In `vi` the same payload lands in the buffer, where `:`-prefixed +fragments are commands that edit and write files. `run_command` now +reads `alternate_on` before sending and refuses, naming the program and +pointing at `send_keys` for raw input. The signal was already there: +`snapshot_pane` reports both `alternate_on` and `pane_current_command`. + +`alternate_on` alone turned out to be necessary rather than sufficient, +and the counterexample is reachable through this server's own tooling: +`less` viewing a `pipe_pane` capture decides the file is binary and +prompts *"may be a binary file. See it anyway?"* **before** entering the +alternate screen, so it owns the keyboard with `alternate_on=0` and the +wrapper ran to a clean `exit_status=0`. A small deny-list of pagers and +editors covers that case. + +The obvious general test — "the foreground command is not the process +tmux started" — was implemented, measured, and rejected. It separates a +prompt from `less`, a `python` REPL and a running command cleanly, but +it also refuses a pane where the user simply ran `bash` inside a `zsh` +pane, and equally `sudo -s`, `ssh` or `nix-shell`, all of which have a +perfectly good prompt. There is no reliable way to ask tmux "is there a +prompt", so the guard errs toward letting calls through and names only +what is known harmful. It is incomplete by construction: a program not +on the list, and not yet on the alternate screen, still receives the +wrapper. + +What that costs is worth stating, because "incomplete" reads as "the +command does not run" and the real consequence is worse than that. +Measured against a `python` REPL: the call is not refused, `timed_out` +and `command_may_still_run` both fire correctly — so the agent is told +the truth — but the REPL is left at a `...` continuation with an +unterminated block, and the *next* input to that pane, from anyone, is +appended to it rather than read as a fresh statement. The pane's own +state survives; nothing is destroyed. That is the line the deny-list +draws: destructive programs are refused, merely disruptive ones are +carried by `command_may_still_run`. + +**A timed-out command is sent, not cancelled.** With the pane blocked on +a foreground command, the keystrokes sit in the terminal's input buffer +and the shell runs them whenever it next reads a line — verified: a +command that reported `timed_out=True` executed once the blocking +`sleep` returned, with no bound on when. An agent reading `timed_out` +alone concludes the command did not run and retries, which is how a +`git push`, a migration or a deploy runs twice, the second time when +nothing is watching. The result now carries `command_may_still_run`, +which says so. + +The wrapper is also sent through the checked send path, so a rejected +send surfaces as an error rather than as a timeout. + +#### `show_option` can answer what is actually in force + +`show_option(option="history-limit", scope="session")` returned +`value: null` while 50000 was in effect. tmux resolves inherited values +with `-A` and libtmux has always accepted `include_inherited`, but the +tool never exposed it — so an agent could ask "is this set at this +exact scope?" and never "what is in force here?", which is what a +question like "is mouse mode on?" actually means. + +`include_inherited` is now a parameter, and the result carries +`scope_queried` so a `null` is readable rather than being mistaken for +"not set anywhere". + +#### `show_environment` no longer encodes removal in the key + +tmux prints a variable it has marked removed as `-NAME`. The dash was +kept in the key and the value set to boolean `true`, so +`variables["KRB5CCNAME"]` raised `KeyError` while +`variables["-KRB5CCNAME"]` answered `true` — reading as "set to true" +for a variable that is explicitly unset, and forcing every consumer to +type-check a `str | bool` mapping. + +`variables` now holds only names that are set, mapped to their values, +and removed names are listed separately under `removed`. + +#### `search_panes` rejects pagination it cannot honour + +`limit=0` returned `matches: []`, indistinguishable from a genuine +miss, and a negative `offset` was clamped to zero while being echoed +back unchanged, so the result silently did not describe the request. +Both now raise. + +#### `search_panes` says how much of each pane it read + +`search_panes` searches only the visible screen unless `content_start` +is given, but reported `matches: []` alongside `truncated: false` — an +active claim that nothing was left out, for a search that never looked +at scrollback. Its docstring described "visible terminal scrollback +content", which reads as scrollback. + +The result now carries `searched_scope` (`visible` / `scrollback`), and +`truncated` is documented as describing the `limit` and per-pane line +caps only. The default stays visible-only deliberately: the tool fans +out across every pane on the server, so defaulting to scrollback would +multiply cost by history depth times pane count and make identical +calls take wildly different times depending on how long panes had been +alive. + +#### `paste_text("")` is a no-op instead of an error + +tmux creates no buffer for empty content, so the follow-up +`paste-buffer` failed with `no buffer libtmux_mcp_..._paste` — an error +for a no-op, naming an internal buffer the caller never chose. + +#### `send_keys` documents its size ceiling + +tmux rejects a `send-keys` argument beyond roughly 16 KB with `command +too long`. The docstring now names that limit and points at +`paste_text`, which routes through a buffer rather than argv and takes +far more. It also warns against verifying a write by string-comparing +captured text: tmux renders combining marks and zero-width joiners as +`` placeholders, so `école` and emoji sequences come back +transformed even when the bytes were delivered correctly. + +#### An oversized response is truncated, not rejected by the client + +A capture large enough to hit the server's 1 MB backstop produced +`RuntimeError: Tool capture_pane has an output schema but did not return +structured content` — a transport-level failure delivering **no data at +all**, which is worse than the truncation the limiter exists to perform. +`capture_pane`'s own docstring advertises `max_lines=None` for a +complete capture, so the documented way to ask for everything was the +way to break it. It is size-driven, not `None`-driven: a large explicit +`max_lines` fails identically. + +The limiter rebuilds the result when it truncates, and that rebuild +dropped `structured_content` alongside `is_error`. The `is_error` half +had already been fixed and documented; the successful-response half was +left standing. Truncated successes now carry structured content again. + +Where the payload's shape cannot be trimmed while staying schema-valid, +the call returns an actionable tool error telling the agent to narrow +its range, rather than a response its client will reject outright. + +Tail preservation itself was correct throughout — the head is dropped, +the newest output kept, and the number of dropped lines reported. + +#### `capture_since` no longer loses a flooded pane silently + +Output that laps a pane's `history-limit` returned **no lines** with +`lines_missed=False`, while tmux still held dozens of them. The field +built to report exactly this loss reported the opposite. + +Two defects compounded. `_cursor_anchor_lost` has no overflow case: +`history_size` climbs to the limit and then stays pinned while rows are +evicted off the top, so none of its three tests fire. And the cursor +fingerprint degenerates to a single hash whenever the anchor was the +last row — which is the normal case, because an agent starts tailing an +idle pane and the anchor is the shell prompt. The uniqueness guard asks +whether a candidate is unique *in the current buffer*, not unique *in +time*, so once the flood evicted the anchor its one surviving twin was +the prompt currently on screen: one candidate, guard satisfied, false +match. Everything above it was dropped as "already seen". + +Matches are now rejected on position when they fall past `anchor_abs` — +tmux evicts only from the top, so a surviving anchor can only move +earlier. Where that is not enough (an anchor taken at the bottom of an +already-saturated history, where the two positions overlap), a +single-hash fingerprint additionally refuses to match inside the +visible region. Declining costs a conservative `lines_missed=True`, +which stays honest: a saturated history means rows were evicted whether +or not the anchor survived. Panes not near their history limit are +unaffected. + +Saturation is asked via the existing trim-risk heuristic rather than +`history_size == history_limit`: measured on tmux 3.7c, a pane with +`history-limit 20` pins at `history_size 19`, so an exact comparison +never fires on the very panes this guards. + +The existing regression test worked around the defect rather than +catching it — its docstring notes that "the flood alone is not +deterministic" and adds a `clear-history` to force anchor destruction, +so the flood-only path real agents hit was never covered. It is now. + +#### `show_hooks()` no longer drops hooks that `show_hook()` finds + +tmux does not unify `-g` listings across its session and window trees, +so `show-hooks -g` omits pane-level hooks that `show-hooks -gw` holds. +A merge existed to paper over that, and its own comment said what it +was for — but it was gated on the caller passing `scope="server"` +explicitly. The natural call, `show_hooks()`, left `scope` at its +default and skipped the merge, so asking "what hooks are configured?" +the obvious way returned an incomplete list with no sign anything was +omitted, and `show_hook()` on a missing name then contradicted it. + +The test named for this behavior only exercised the explicit-scope +path, so the default one was never covered. + +#### `list_servers` rows carry a complete identity + +The directory scan held each socket's full path and reported only its +name, so `socket_path` was null on every scanned row. Passing that same +socket through `extra_socket_paths` then listed it a second time +carrying the opposite half of its identity, with nothing to tie the two +rows together — an agent could not tell they were one server. + +Scanned rows now carry both fields, and extras are deduplicated against +the scan by resolved path. A socket whose name does not round-trip +through `tmux -L` — one containing a newline, say — is listed by path +instead of being dropped from the results without a word. + +#### `wait_for_text` reports a stop marker that was already on screen + +The entry scan covered `patterns` and not `stop`, so waiting on a pane +that already showed a failure marker returned a bare `timeout` with no +sign the marker had been there the whole time. The realistic shape: an +agent runs a build, it fails, the agent waits for the next build without +clearing, and reads `timeout` as "still running" when the honest answer +is "the previous run already failed". + +`WaitForTextResult` gains `stop_matched_at_entry`, kept separate from +`matched_at_entry` because a stale success marker and a stale failure +marker call for opposite reactions. A stale stop hit still does not end +the wait — only a fresh one does. + +The server instructions described this as `stop=[] bails`, which two +independent readers parsed as "the empty list bails". `stop=[]` is +accepted and behaves like `stop=null`; it is a stop *hit* that returns +immediately, and the text now says so. + +#### A live server no longer reports as absent + +When the tmux binary running this server is older than the one that +created a socket, `get_server_info` returned `is_alive=False, +session_count=0` and `list_sessions` returned `[]` — both without error. +An agent reads that as "the user's work is gone". `list_panes` did error, +so two of four calls answered with a confident falsehood. + +`Server.is_alive()` answers `False` both for a socket with no daemon and +for a live server this binary cannot speak to, and `Server.sessions` +degrades to `[]` in both cases. libtmux's own docstring points at +`is_alive` to tell the two apart, but it cannot — they collapse to the +same `False`. tmux itself distinguishes them on stderr, so that is read +instead of the boolean. + +`list_sessions` now raises rather than claiming a server it could not +query is empty, and names the likely cause. `ServerInfo` gains +`unreachable_reason`: when it is set, `is_alive=False` means "could not +ask", not "not running", and `session_count=0` carries no information. + +The trigger is an ordinary tmux upgrade — sockets outlive the binary +that made them. The boundary is between tmux 3.5 and 3.6, and only in +the old-client-to-new-server direction; a newer binary reads an older +server correctly. + +#### A rejected copy-mode command is no longer reported as success + +`exit_copy_mode` on a pane that was not in a mode returned a full +`PaneInfo`, which reads as confirmation the pane left copy mode. tmux +says `not in a mode` and exits 1. `Pane.send_keys(copy_mode_cmd=...)` +discards that result, the same way the ordinary send path did. +`enter_copy_mode(scroll_up=...)` used the same unchecked call. + +#### A newline in a directory name is diagnosed, not "Unexpected error" + +A pane whose current directory contains a newline makes libtmux fail to +parse `-F` output, and because every pane lookup enumerates panes, the +whole tmux server stops resolving — healthy panes included. It reached +the agent as `Unexpected error: ValueError: zip() argument 2 is shorter +than argument 1`, logged at ERROR, naming nothing it could act on. The +agent also could not repair it through the MCP, since every tool that +could move the pane needed the same enumeration. + +It is now an expected failure that names the cause and how to find the +offending pane. The parse itself is fixed in libtmux +([#752](https://github.com/tmux-python/libtmux/pull/752)); this +diagnosis stays useful regardless, because the installed libtmux +version is not this package's to choose. + +#### `Pane not found:` is no longer said twice + +`exc.PaneNotFound` prefixes its own message and the error mapper +prefixed it again, so the most frequently hit error in the server read +`Pane not found: Pane not found: %9999`. + +#### A typo in a filter field is an error, not an empty list + +`list_sessions`, `list_windows` and `list_panes` validated the *operator* +half of a Django-style filter key and never the *field* half, so +`filters={"nosuch_field__contains": "x"}` returned `[]`. A key with no +`__` at all, like `{"totally_bogus": "zzz"}`, was not checked by +anything. libtmux's `QueryList` resolves a filter key by attribute +traversal and treats a miss as "no match", so a misspelled field +silently filtered every row out and the empty result was +indistinguishable from a genuine one. + +Field names are now checked against the object being filtered, with +near-misses suggested: + +``` +Unknown filter field 'session_nme' in 'session_nme__contains'. +Did you mean: session_name, session_marked, session_id? +``` + +Validation covers only the leading segment of a key, so nested +traversal such as `active_window__window_name__contains` keeps working. + +#### A pane that dies mid-wait reports the death, not a parse crash + +Killing a pane while `wait_for_text` was waiting on it surfaced +`Unexpected error: ValueError: invalid literal for int() with base 10: +''`. tmux expands every field of a vanished pane to the empty string, +and three `int()` calls on the poll path took it raw. + +Fixing only the `int()` calls would have replaced the crash with a wrong +answer: `pane_dead` is one of the blanked fields, so it reads back as +`"0"` and cannot report the death itself. A killed pane would then have +failed the pid comparison instead and been reported as *respawned*. A +live pane always has a `pane_pid`, so an empty one is the reliable +signal that the pane is gone, and the wait now ends with `pane %N died; +cursor/baseline anchor is no longer valid`. + +`_read_history_limit` had the same defect one call over — its guard +covered an empty list but not an empty string — and now shares the +helper. + +#### `send_keys` no longer drops text that starts with `-` + +`send_keys(keys="-X cancel", literal=True)` returned `Keys sent to pane +%N` and sent nothing. tmux parsed the payload as flags and rejected the +command; the wrapper discarded tmux's result and returned a hardcoded +success string. Reachable with ordinary input — `--help` or `-v` typed +into a REPL, a negative number, a pasted diff line. + +The `send-keys` argv now ends flag parsing with `--`, and a failed send +raises with tmux's own stderr instead of reporting success. This covers +`send_keys` and both `send_keys_batch` paths, which each built the argv +separately; the timed batch path surfaced the error but still failed to +deliver. + +#### A gated tool now says which tier it needs + +Calling a tool above the server's `LIBTMUX_SAFETY` tier reported +`Unknown tool: 'kill_pane'` — the server denying that its own gated tool +exists. An agent told a tool is absent reports the capability as missing +rather than naming the setting that enables it. + +Off-tier calls now name both the tier the tool requires and the tier in +force: + +``` +Tool 'kill_pane' requires safety level 'destructive', but this server is +running at 'mutating'. Restart it with LIBTMUX_SAFETY=destructive to +enable it. +``` + +The message previously hardcoded `LIBTMUX_SAFETY=destructive` for every +denial, so a `readonly` server answered a `send_keys` call by advising +the strongest tier — telling a user to grant `kill_server` rights in +order to type into a pane. The tier named is now the one the tool +actually needs. + +Two gates were always intended here, and only one of them worked. +FastMCP's native `disable()` enforces the tier; `SafetyMiddleware` was +meant to explain it. Because `get_tool()` answers `None` for a disabled +tool, the middleware's `if tool and not allowed` guard never ran for the +tools it was written for. Off-tier names now resolve against the full +registry, which retains disabled tools and their tags. + +Denials also reach the audit log as denials again. The server's +middleware ordering is built so that a tier denial raises inside +`SafetyMiddleware` and is recorded by `AuditMiddleware` outside it; +while the denial never fired, a blocked call was audited as an +unknown-tool error instead. + +The batch wrappers carried the same defect through a second call path: +`_get_allowed_tool_tier` also read `get_tool` and raised "Unknown tool" +on `None`, so a gated tool and a misspelled one produced byte-identical +rows. It now hands the operation on instead of duplicating the lookup — +the nested call runs through the middleware, which names the tier for a +gated tool while a real typo still gets FastMCP's own error. + +`SafetyMiddleware.on_call_tool` additionally fails closed when no +FastMCP context is present. It previously fell through to the tool, +which was fail-open in a gate whose top tier includes `kill_server`, and +was masked only because the native gate made the dispatch fail anyway. + +#### Every tmux call is bounded, not just the first one + +A tmux server can accept connections and then never answer. Every tool +carries a five-second liveness probe for that, and the probe covers the +tool's FIRST round trip only. `break_pane` makes eleven. Measured +against a socket that answers the probe and stalls afterwards, it was +still running at 150 seconds; healthy, the same call takes 0.13s. + +It was never one tool. libtmux reaches tmux through an untimed +`Popen.communicate()`, so roughly 57 tools had no bound of their own, +and being an `async def` did not help -- the tool batches are +coroutines and hung exactly the same way. + +Worse than one slow call: a hung call never returned its worker, and +cancelling the request did not interrupt it, so hung calls only +accumulated. Forty of them -- at once, or one at a time with a cancel +between each -- exhausted the thread pool, after which the server +stopped answering everything, including sockets that were perfectly +healthy. Forty is reached by an agent behaving correctly: call a tool, +give up when it does not return, try again. + +Now every tmux call is bounded and refuses with the subcommand that +stalled: + + tmux list-panes did not return within 5.00s; the tmux server is unresponsive + +`break_pane` against that socket: 150s+ and unrecoverable, now 5.01s, +worker returned, and the tmux client killed rather than left behind. +Verified on all nine supported tmux versions. + +The bound sits at libtmux's `tmux_cmd` rather than at `Server.cmd`, +because `Server.cmd` is not the only way there -- `neo.fetch_objs` +builds one directly, and it is what `Window.panes` and +`Session.windows` go through. A test walks the installed libtmux for +call sites and fails if one appears outside the bound set. + +#### The audit log's redaction survives a short secret + +Sensitive arguments are recorded as `{len, digest}` rather than +verbatim. The digest was an unsalted SHA-256, and the recorded length +fixes the search space exactly — so a four-digit PIN typed into a pane +was recoverable from its own audit entry by brute force in 25 +milliseconds. `keys`, `text` and `value` are treated as sensitive +precisely because short secrets are what agents type into panes. + +The digest is now HMAC-SHA256 under a random per-process key. Identical +payloads still correlate across log lines within one server run, which +is the scope an operator reads a log at, and someone reading the log +can no longer test a guess against it. Correlation across separate runs +is what this costs. + +Sensitive list arguments are also redacted whatever their items are, +rather than only their strings. Nothing leaked — every such argument is +a list of strings today — but that was a property of the annotations +rather than of the redaction. + +#### `filters` works the same whichever way your client sends it + +`filters` takes a dict or a JSON string, and the docs said the two were +interchangeable. They were not: the dict form was validated as +`dict[str, str]` and rejected a boolean, while the JSON-string form was +decoded into a dict holding a real boolean that nothing re-checked. + +The example that diverged is the one that matters most. +`list_panes(filters={"is_caller": true})` is how an agent answers +"which pane am I in?" -- there is deliberately no whoami tool, and the +server instructions point at it three times. Clients that send objects +got a validation error on first contact; clients that send strings +never saw it. + +Both forms now validate identically, and typed values are compared the +way tmux reports the field: every tmux-derived attribute is a string, +so `{"pane_width": 80}` matches exactly what `{"pane_width": "80"}` +matches. Accepting a type without that would have swapped a validation +error for a confident empty list -- an agent told there is no such pane +rather than that it passed the wrong type. + +#### `wait_for_text` reports progress on a cadence, not per poll + +Progress was emitted once per poll iteration, so the notification rate +was set by `interval` -- about nine notifications a second at the +default, and nominally ~3,000 over a full-length wait at the 0.01 +floor, each carrying the same sentence with a different decimal. (The +wait ceiling is 30 s by default; 120 s is only what the environment +variable can raise it to.) + +It now uses the same one-second ticker as `run_command` and +`wait_for_channel`, so the wait family is consistent and the rate no +longer depends on a polling knob. A wait shorter than a second reports +nothing, which is what the other two already did. + +#### One sentence for an unresponsive tmux server + +Every tool that gives up on a tmux server now says the same thing: + + tmux did not return within 5.00s; the tmux server is unresponsive + +`load_buffer`, `show_buffer` and `delete_buffer` reported the same +condition as `show-buffer timeout after 5s` because they reach tmux +directly rather than through libtmux. They were always bounded -- they +never had the hang above -- but an agent or a doc matching on the +phrase got it right for 57 tools and wrong for three. The buffer name +is still named. + +#### `paste_buffer` takes `delete_after` + +`delete_buffer`'s description told agents not to bother after +`paste_buffer(delete_after=True)`. That call did not validate: the +parameter existed on the libtmux method underneath and never on the +tool, and both are spelled `paste_buffer`, so the prose was true of the +implementation and false of the interface. + +It now exists, pastes and deletes in one tmux call, and says which it +did. A test cross-checks every `tool(kwarg=...)` reference in every +tool description against that tool's schema, so a description cannot +again promise an argument that is not there. + +### Fixes + +#### Nested tmux no longer defeats the self-kill guard + +`TMUX` names only the INNERMOST server. Run an agent inside tmux and +point it at a second tmux, and the pane hosting its terminal belongs to +the OUTER server while `TMUX` describes the inner one -- so every +socket comparison in the guard said "different server" and a kill of +that pane was permitted. It takes the caller's tty with it, which is +exactly the self-kill the guard exists to prevent. Reproduced on 3.7c: +guard against the inner server True, against the outer server False. + +Reachable rather than theoretical: `list_servers` enumerates every +socket, so a nested agent can see the outer one and target it. + +The fix asks WHO IS ATTACHED instead of how the nesting arose. A client +of the caller's own server occupies a pane of whatever hosts it, so the +inner server's `client_tty` is the outer server's `pane_tty` -- +measured, both `/dev/pts/50`. That covers a server merely attached to +as well as one started from a pane, and it needs no `/proc`, which +macOS does not have; walking the process tree would have missed the +first case and silently protected only Linux. + +A hung probe fails closed, matching the guard's existing bias. A +nonzero exit does not: "no such server" is an ANSWER -- one that is +gone hosts nothing -- and treating it as unknown would block every +destructive call for a caller whose `TMUX` names a socket that has +since died. + +Found by the QA instance from inside a real nest. Its control is what +makes the fix trustworthy: an unrelated third server must still be +killable, or the guard has merely stopped answering. + +#### A spawn start directory is a literal path + +`create_session` escaped its `start_directory` because `new-session -c` +is format-expanded. `split_window`, `create_window` and `respawn_pane` +did not, and they are expanded too -- all four are. + +The first sweep got this wrong by READING rather than measuring: +`cmd-split-window.c` assigns `sc.cwd = args_get(args, 'c')` with no +expansion in sight, so it was recorded as safe. The expansion happens +further down, in the spawn path. Measured on all four commands, a `#S` +in the path moves. + +Failure is silent, which is what makes it worth a fix rather than a +note. An unescaped `#` does not error: tmux expands the path into one +that does not exist and starts the shell in `$HOME` instead. The tool +reports the pane it created, the pane is real, and the caller never +learns it is in the wrong directory -- the same shape as the +`create_session` empty-name defeat, where validation ran on a string +tmux then rewrote. + +The regression test covers all four spawns and was checked to fail +without the escape. + +#### `display_message` stops refusing a literal `#(` + +The job guard tested for the substring `#(`, but `##` is tmux's escape +for a literal `#`, so `##(` is text -- tmux renders `pane ##(literal)` +as `pane #(literal)` and runs nothing. The blunt check refused it, so a +label or a code snippet containing `#(` could not be printed. + +Parity is what decides it, and for the same reason the escaper works on +runs rather than characters: `format_expand1` consumes `#` pairs into a +literal `#` before it ever looks at `(`, so only an ODD run leaves a +bare `#` to open a job. `#(` and `###(` are jobs; `##(` and `####(` are +text. The guard now measures the run. + +Every real job stays blocked, including the odd longer runs and a job +nested inside a conditional. Found by the QA instance, whose security +probe confirmed the guard holds at the readonly tier -- `#(...)` is +refused before reaching tmux -- and that no expander-reintroduction +route (`#{E:...}`, a space after the `#`) reaches execution on 3.7c. + +#### `signal_channel` documented the footgun as a safe habit + +The description said "signalling an unwaited channel is a no-op that +still returns successfully -- safe to call defensively". It is not safe, +and defensive repetition is exactly what breaks it. + +tmux latches a signal nobody is waiting for, which is what makes this +primitive better than polling. A SECOND signal on a latched channel with +no waiter destroys the channel, latch included, and the next wait blocks +to its ceiling. It toggles rather than saturating -- measured: one +signal returns in 0.04s, two blocks, three returns in 0.03s. +`cmd_wait_for_signal` guards the latch with `TAILQ_EMPTY(&wc->waiters) +&& !wc->woken`, so an already-woken channel falls through to the +wake-the-waiters path, finds none, and calls `cmd_wait_for_remove`. + +Not papered over: reading the latch first is a race and there is no +non-destructive way to ask. Documented instead, in the tool description, +the tool page and the gotchas topic, and pinned by a test across the +supported tmux matrix -- a cited measurement goes stale silently +otherwise. + +Found by the QA instance, which also confirmed the attack this pair was +most at risk from is NOT present: a server that dies mid-wait is +reported as a failure, not as a signal, and a graceful `kill-server` and +an abrupt SIGKILL give different messages. + +#### `resize_pane` refuses a resize that cannot happen + +`resize-pane` on the only pane in a window exits 0 and changes nothing: +the pane already fills the window and there is no neighbour to take +rows from. Measured on a lone pane at 30 rows, `-y 11` left it at 30. + +Nothing lied -- the returned `PaneInfo` carried the real size -- but no +field said the REQUEST went unmet, and a caller has no reason to +suspect the comparison needs making. `split_window` already refuses an +unsatisfiable size rather than doing something else quietly; this now +matches it. tmux clamping a resize to what the neighbours can give is +its own semantics and is left alone. + +The reason it survived is worth recording: `test_resize_pane_dimensions` +exercised a LONE pane and asserted only that the returned `pane_id` +matched the one passed in -- which a complete no-op satisfies. The test +has been given a neighbour and now compares the size against the value +read before the call. + +Found by the QA instance while sweeping for tools whose result cannot +confirm their own effect. + +#### A missing tmux server no longer advises a call that cannot work + +With no server running, resolving an object raised `Object not found` +and suggested calling `list_sessions` to discover valid ids -- a call +that fails identically, so the advice sent the caller round the loop it +was already in. + +The information to answer properly was already being gathered: +`_probe_liveness` separates "no server" from "server exists but cannot +be queried", and only the second had a message. Both do now. A missing +server says so and names the socket. + +#### An oversized `send_keys` payload is the caller's problem, not a crash + +`send_keys` reported `Unexpected error: OSError: [Errno 7] Argument +list too long`, which tells an agent the server is broken when what +happened is that its input was too big. + +Two regimes, and only one of them was handled. Linux caps a SINGLE argv +element at `MAX_ARG_STRLEN` (32 pages, 131072 bytes) independently of +the total, and the boundary is exact: at 131071 bytes tmux runs and +rejects the argument cleanly with `command too long`, at 131072 +`execve` fails and tmux never starts. Every `subprocess.run` in this +server caught `TimeoutExpired` and `CalledProcessError` -- both of +which mean tmux RAN -- so the failure that happens *before* tmux runs +had no handler anywhere. + +Fixed as a class rather than at the one reported site: a shared mapper +turns an exec-time `OSError` into an `ExpectedToolError` naming the +limit and pointing at `paste_text`, and it is wired into every call +that puts caller-sized data in an argv -- `send_keys`, +`send_keys_batch` and `signal_channel`. `send_keys`'s own size note now +gives both limits instead of only tmux's. + +Found by the QA instance, which also caught its own instrument error in +the same pass: its pass/fail classifier keyed on the substring "too +long", which appears in both `command too long` and `Argument list too +long`, so its first run scored the unhandled path as handled. + +#### Shutdown no longer crashes while sweeping leaked buffers + +Lifespan teardown deletes any `libtmux_mcp_*` paste buffers an +interrupted call chain left behind, one tmux round trip per cached +server. It walked the live cache of servers to do that, so a tool call +that cached a new server inside the window resized the collection +mid-walk and shutdown died with `RuntimeError: dictionary changed size +during iteration`, leaving the remaining servers unswept. + +Teardown now claims the cache in one atomic step and sweeps the +snapshot, which also keeps those round trips off the lock every other +cache reader waits on. + + +### Documentation + +#### How to read a green run, and what a loaded one costs + +Two instrument caveats are now in the contributing guide, because both +cost real time to rediscover. + +`--reruns=2` is in `addopts`, so a passing summary means "did not fail +three times consecutively". The rerun lines now name absorbed failures +(see Development), but the counts still say `passed`, and the way to +hunt a flake is `--reruns 0`. + +Separately, a sparse population of failures under heavy parallel load +shares one symptom: `libtmux.exc.WaitTimeout` at just over 10 s from +libtmux's `retry_until`. There is no knob for it. libtmux exposes +`RETRY_TIMEOUT_SECONDS`, and it is inert here -- all 77 call sites in +this suite pass a timeout explicitly, 73 of them the literal `10`, so +none reads the environment. Counted by walking the AST: a regex over +these calls miscounts, because the predicate is usually a lambda +carrying its own parentheses. + +No change is indicated. The bound is a ceiling rather than a spend -- +the polls complete in 1.4-4.3 s, so 2-7x of margin -- and both observed +failures needed loadavg above 200 on a 20-core box. CI runs at far +lower parallelism and has never shown it. + +Found by the QA instance, which then corrected its own attribution: it +had reported the shared bound as the environment default of 8 s "plus +overhead", and the rerun timings in its own table clustered at 10, not +8. The AST count above was run to check the correction rather than +accept it, and made it stronger -- the estimate was "at most 46 sites +take the default"; the answer is none. + +The same instance also verified this release's `TMUX_TMPDIR` isolation +against the detector that exposed the class rather than against a +stopwatch: re-planting the 48 silent listeners that had produced ++12.00 s and +6.00 s now produces 0.01 s and 0.01 s, with the machine's +1777 sockets still in place. + +#### The batch speed curve named a break-even that does not exist + +`call_readonly_tools_batch` claimed batching "is not a speed win below +about three or four operations", with a curve to match. The curve was +measured with one cheap single-value read and then stated as a general +rule. Measured over stdio across four read tools, the ratio at a SINGLE +operation ranges from 0.71 to 1.83 -- the same n=1 both wins and loses +depending on which tool is nested -- so no count-based break-even can be +stated at all. + +The cost of that was real rather than cosmetic: the numbers led, the +qualifier that invalidated them ("it pays most when the nested +operations are individually expensive") trailed, and an agent reading +only the first sentence would skip batching two operations that batching +wins. The docstring now leads with the dependency and points at the +per-operation `elapsed_seconds` already in the result, so a caller can +settle it for its own mix. + +Reported with the measurements by the QA instance, and then narrowed +by both instances together. The tmux-facing work is near-uniform +(7-10 ms, one subprocess round trip whichever tool it is), so the +spread is not tmux. It is not response size either: holding everything +else constant, 455x the returned bytes cost 18% more time, while +`get_pane_info` returns 53x FEWER bytes than a large `capture_pane` and +costs 2.3x MORE. And it is not stdio framing, which measured 0-4 ms -- +the same 2.7x spread is already present through an in-process client. +What is left is FastMCP's own dispatch and output validation. Since +neither instance could pin a rule finer than that, the docstring states +the dependency and points at the measurement already in the result +rather than predicting it. + +#### `show_option(scope=...)` pointed nowhere for tmux's `-g` + +`scope="global"` is the first thing to try, since `-g` is tmux's own +vocabulary, and it failed schema validation with an enum error that +never mentioned `global_`. `-g` is not a scope here -- it is orthogonal, +because session and window options each have a global level -- and the +`scope` and `global_` descriptions now say so. + +#### The safety tier gates tools, not what a shell can type + +`LIBTMUX_SAFETY=mutating` hides the destructive tools, and a caller can +reasonably read that as "this MCP cannot kill my windows". Measured, at +that tier and with `kill_window` absent from the tool list entirely: + + run_command(command="tmux -L kill-window -t @1") + exit_status 0 -- the window is gone + +That is not a hole to close: a verb guard on command text is bypassed by +`t=tmux; $t kill-window`, and refusing it would break the tool. It is a +scope the docs had not stated, so the footgun section now states it, and +says which tier does hold the distinction (`readonly` — it exposes +neither the destructive tools nor the ones that type). + +Raised by an unrelated agent session driving this MCP for real work, +which fell back to shelling out and asked whether the gate meant +anything. + +#### opencode joins the install picker + +The install widget gains an opencode panel. `opencode mcp add tmux -- ` +is non-interactive once a name and a `--` command are both given, so it is a CLI +panel rather than a paste-this-JSON one — which also sidesteps opencode's +unusual entry shape. + +### Development + +**Which mutation falsifies which assertion** + +"The test failed when I broke it" is a claim about the mutation +chosen, not about the test. A multi-assertion test needs one mutation +per line, because the first failing assertion stops the test and the +lines after it are never reached. + +Shown on `kill_server`'s refusal test by the QA instance: disabling +the guard falsifies the `raises` block and never reaches +`assert mcp_server.is_alive()`. Only a guard that raises the RIGHT +error and kills anyway falsifies that one -- so the liveness check is +load-bearing, catching a tool that refuses in words and kills in fact. +Per-test mutation would have called it covered after the first. + +Applied to the nested self-kill test in turn, which has three rows: +removing the nesting check falsifies the OUTER row; making it return +True unconditionally falsifies the CONTROL row, so that control really +does catch a guard that has stopped answering. Breaking the primary +realpath match falsifies NOTHING -- the inner row is satisfied by a +fallback route and does not isolate that path, which +`test_caller_is_on_server_matches_realpath` covers instead. Both +docstrings now record this rather than leaving a reader to assume each +assertion earns its place. + +**`kill_server` gets a functional test** + +The tool that destroys every session on a server had no test of what it +does -- only middleware checks that its NAME is gated at the right +safety tier. Both halves are covered now: it kills a throwaway server, +verified by asking the server rather than by reading the return string +(a tool that answered "Server killed successfully" and killed nothing +would have passed on the message alone), and it refuses when the caller +is on the target, verified by the server still being alive afterwards. + +The refusal test needs `mcp_session`, not just `mcp_server`: the bare +fixture constructs an unstarted `Server`, so "is it still alive" would +have answered False whether or not the kill happened. Closing one of +the gaps the QA instance had declined -- correctly -- to test against +its own live socket. + +**Every test gets its own tmux socket directory** + +`list_servers` probes every socket in `TMUX_TMPDIR`, so a test that +scans without isolating pays for the machine's accumulated debris -- +1785 sockets on one development box. Quiet, each is about a +millisecond and invisible; under load their cost inflates and there are +1785 of them. + +The release note above fixed the ONE test that noticed. It was the only +one with a duration assertion, so it failed visibly at loadavg 90 while +two siblings paid the same cost in silence, asserting only presence and +liveness. Measured by the QA instance with 48 planted silent listeners +and an A/B null control to establish the noise floor: + + list_servers_reports_a_complete_identity_and_dedups +12.00s + list_servers_finds_live_socket +6.00s + ++6.00 is exactly `ceil(48/16) x 2.0`; +12.00 is twice that because +that test scans twice. Under real load with only the machine's own +litter and nothing planted, they reached 16.9 s and 40.4 s against +0.10 s and 0.25 s quiet. + +So the assertion was what made one visible, not the defect, and the +population is "tests that touch the scan" rather than "tests that +failed". Nothing isolated `TMUX_TMPDIR` suite-wide -- it was per-test +opt-in and three tests opted in. An autouse fixture now gives every +test its own directory, which closes it for tests that do not exist +yet. After: 0.01 s and 0.02 s, with the machine's 1781 sockets still in +place, because passing on a tidied directory would prove nothing. + +Two tests had hardcoded `/tmp/tmux-/` for the fixture socket and +now read `TMUX_TMPDIR`, which is where it has always actually been. + +**An absorbed failure names itself** + +`--reruns=2` is in `addopts`, so a green summary meant "did not fail +three times consecutively". Six single failures were absorbed in +silence across one QA series -- including one of the very tests whose +fix that series was verifying. A retry policy is reasonable for flaky +infrastructure and it is also load-sensitivity's perfect camouflage, +switched on during a hunt for exactly that. + +`-rfER` is now in `addopts`, so each rerun prints its nodeid even when +the run passes. Verified against a test that fails once and then +passes: the run reports `1 passed` AND `RERUN `, where before +it reported only the first. + +**The silent-socket test measured the machine, not the code** + +`test_list_servers_survives_a_socket_that_never_answers` asserted the +scan finished in under 10 s, and failed deterministically at loadavg +90 -- once by 28 ms. The arithmetic looked like the cause: +`_SCAN_WORKERS` is 16 and `_PROBE_TIMEOUT_SECONDS` is 2.0, so a wedged +socket costs one probe timeout per batch and contention stretches it. + +The real multiplier was elsewhere. The test never isolated +`TMUX_TMPDIR`, so the scan also probed every socket in the machine's +shared directory -- 1785 of them on the development box. Quiet, those +are about a millisecond each and invisible; under load their per-probe +cost inflates and there are 1785 of them. The test was measuring +accumulated socket litter, which is why it failed on one box and not +the other. The sibling test immediately below it already isolates, for +a related reason. + +It now scans an empty `TMUX_TMPDIR` and reaches the silent socket +through `extra_socket_paths`, exactly as before. Measured at 87 +runnable on 20 CPUs: 2.07-2.16 s, against 10-12 s before, and +indistinguishable from a quiet box. + +The bound is also derived from `_PROBE_TIMEOUT_SECONDS` rather than +written as 10.0, so it moves when the constant does. A literal was +asserting the machine's speed rather than the code's behaviour. + +**Three more load-sensitive tests, from a series at loadavg 85** + +`matches_a_reprinted_line` needed a third pass, and the numbers said +why: `saw_new_output` False, `effective_timeout` 12.0, and the marker +PRESENT in `rows`. So the reprint had arrived and been captured -- it +just landed before the wait took its entry baseline, and was counted as +entry text rather than as new. Raising the ceiling could never fix +that. The gate now releases on OBSERVING the baseline capture rather +than on assuming it: `wait_for_text` reads pane state and then +captures, so the first `capture-pane` means the snapshot is being +taken. Verified the observation is load-bearing by pointing it at a +subcommand that never runs and watching the test time out. + +`send_keys_batch_sends_operations_in_order` timed out on a 5 s channel +wait. That bound is a CEILING -- the shell signals in milliseconds and +the wait returns with it -- so it, and the three like it, now come from +one named constant at 20 s carrying the reason. + +`a_mutating_tool_answers_with_a_freshly_read_record[respawn_pane]` was +a FALSE POSITIVE of this release's own freshness guard, and the QA +instance that specified the guard called it: comparing the tool's read +against a later one asserts equality between two observations of a +MOVING system. `lifecycle.py` already measures `pane_current_command` +lagging `pane_pid` by a median of 14 ms after a respawn, and under load +that lag outlasts the settle loop. A difference confined to that field +is now reported as settling rather than staleness. Nothing is lost: +`pane_pid` changes the instant `respawn-pane` returns, and with the +refresh deleted the guard still fails, naming `pane_pid` and +`pane_tty`. + +**Three load-sensitive test assertions, fixed without loosening one** + +The QA instance turned a one-in-ten mystery into one-in-one by +generating sustained load, and named three tests a quiet machine never +fails. All three asserted something narrower than the property they +guard; none was a product defect. + +`test_wait_for_channel_detects_a_vanished_server` pinned one of TWO +honest messages. Reproduced deterministically: a server already gone +when `wait-for` runs gives tmux's own "no server running on ", +while one that dies mid-wait gives the liveness re-probe's "no longer +running". Under load the kill can beat the child's connect, so the +wrong one arrives. Widening the match would have been worse than the +flake -- with the re-probe REMOVED, tmux's error still raises and +`pytest.raises` is satisfied, so all three cases would pass. It now +asserts the phrasing when the precondition held and skips with a reason +when the waiter never connected. Verified: with the re-probe disabled, +two of three fail and one skips. + +`test_the_silent_waits_now_report_progress` needed ticks inside a fixed +0.6 s window. Its own comment already said load can only DROP ticks, +never add them -- a race lost in one direction only. It now waits on an +`asyncio.Event` set by the first progress callback, with a ceiling that +is a bound rather than a spend. + +`test_wait_for_text_matches_a_reprinted_line` put the reprint on a +timer 1.6 s after the shell started, so respawn plus the marker poll +had to win a race against it. The shell now blocks on a `wait-for` +gate that the test releases once the wait is running, establishing the +ordering instead of racing for it -- and it runs in 5.7 s rather than +8 s, because the `sleep 1` is gone. + +`test_wait_for_text_matches_a_reprinted_line` needed a second pass. +Gating fixed the ordering, and the 4 s bound then became the binding +constraint -- at 91 runnable it timed out with `saw_new_output` False, +meaning the shell had not produced anything yet. That bound is a +CEILING for the reprint case (it returns the moment the reprint lands, +about a second in), so raising it to 12 s costs nothing when it is not +reached. The stale case SPENDS its timeout, so that one stays at 4 s -- +and now skips when no output arrived at all, because `found is False` +is trivially true when nothing was written and the falsifier never ran. + +Measured: nine runs at 44-126 runnable on 20 CPUs. The only failures +were of the reprint case at its old 4 s bound, and the only skips were +preconditions honestly reported. + +**A slow-shell timeout is no longer read as a refusal** + +`test_run_command_allows_a_slow_shell` guards one property: a shell +that is slow must not be REFUSED as one that never started. The +refusal raises, so it fails the test whatever the machine is doing. But +the test also asserted `exit_status == 0`, which turns a plain +load-induced timeout -- a different outcome that disproves nothing -- +into the same red. + +The margin is thin by construction, not by oversight. The preexec hook +holds the shell 6s and the whole budget is 10s, leaving under 4s for +everything else; measured at 6.6-7.8s in isolation. The budget cannot +be raised to buy room, because the started-channel grace is +`max(5, timeout/2)` and grows with it -- above 10 the grace stops +expiring before the command starts and the scenario is no longer the +one under test. + +So a timeout now skips with its reason, and the refusal still fails. +Verified reachable by squeezing the budget to 6.5s and watching it skip +rather than fail. + +Honest about the evidence: this was seen failing once directly, under +`-n auto`, passing in isolation, and the QA instance saw one +unidentified failure in five runs on an independent machine. Eight +further runs here were clean, so the rate is low and it has NOT been +reproduced on demand. The change is justified by precision -- the +assertion now matches the property the test names -- rather than by a +measured rate. + +**A local dependency pin can no longer travel** + +Commit 1367707 added a `[tool.uv.sources]` path entry pointing libtmux +at a sibling worktree, so the server could be exercised against +unreleased libtmux fixes. It carried a comment saying it must not reach +a pull request -- and a comment was the only thing holding it. + +Two things were wrong beyond the reminder. The entry is committed, so +pushing the branch pushes it; and `uv.lock` records the resolved source +separately, so deleting the stanza alone would leave the lock pointing +at the sibling. The comment also claimed "CI resolves libtmux from +PyPI", which is false: CI runs `uv sync --all-extras --dev`, and uv +reads `[tool.uv.sources]` from `pyproject.toml`. It would have resolved +the relative path and failed before a single test ran -- a claim about +a gate nobody had executed, which is the class this branch keeps +fixing. + +The pin is removed, and two guards assert its absence: no path source +in `pyproject.toml`, and no editable in `uv.lock` outside the project +itself. Verified to fail on a reinstated pin and pass without one, so a +future local pin announces itself instead of waiting to be noticed. + +Found by the QA instance, whose worktree could not resolve dependencies +at all -- the first thing all day to touch dependency resolution rather +than the tool surface. + +**Record atomicity is asserted, not left to fall out** + +A pane record cannot be torn -- half its fields from before a +concurrent mutation and half from after -- because tmux expands a whole +`-F` format in one pass per pane, so a mutation lands strictly before +or strictly after it. Nothing in this server arranges that. It falls +out of asking once for everything, which is why it needed a test: +splitting the fat format into targeted queries ("why fetch forty fields +to serve six") is a plausible optimisation that would make torn records +possible for the first time, and nothing would notice -- a torn record +is inconsistent only sometimes and passes every schema. + +The guard requires exactly one `list-panes` per record and requires +that single format to carry the geometry fields the atomicity argument +rests on. It is deterministic rather than probabilistic: a concurrency +assertion catches a torn record only when the race lands, while this +fails the moment the format is split. Verified to discriminate -- two +records produce two listings. + +Same remedy as the freshness guard and the `tmux_cmd` drift test: +assert the property, not the placement. The QA instance named the +family these three share -- an invariant held by convention rather than +by construction, correct today and silently breakable by the next +author. + +**A returned record is asserted fresh, not assumed fresh** + +libtmux objects hold their fields as plain attributes populated when the +object was built, so serializing one the server has held for a while +returns what was true then -- a record that is internally coherent, +satisfies every geometry invariant, and is wrong, with nothing at the +call site to reveal it. Eight hand-placed `refresh()` calls keep the +mutating tools honest today, and the QA sweep confirmed all five it +tested return post-mutation state. + +Eight placements with no invariant behind them is the shape that lets a +ninth tool forget. A parametrized guard now calls each mutating tool +that answers with a `PaneInfo` and compares the result field-by-field +against a freshly resolved one, so the PROPERTY is asserted rather than +the placement, and a future refactor may move the refreshes freely. + +Verified to have teeth in both directions: a synthetic stale record is +rejected by the same comparison, and deleting the real `refresh()` in +`respawn_pane` fails the guard on a changed `pane_pid`. + +- `just lint-ci`, `just test-ci` and `just deps-ci` reproduce CI's + gates locally. They existed nowhere before, and two of the recipes + that looked equivalent were not: `just ruff-format` rewrites what CI + rejects, and `just mypy` checks a `find`-derived file list rather + than `mypy .`. The formatter is also the only gate that reads Python + inside Markdown fences, so it was the single check capable of + catching an unformatted example -- and its local recipe silently + repaired one instead. +- `just deps-ci` builds a throwaway environment and asserts it is + actually dev-free. `uv run --no-dev` alone reuses `.venv`, which has + pytest, ruff and mypy in it; CI only avoids that because its check + runs before dependencies are installed. +- CI's tmux matrix gates 3.7c. It ran 3.2a through 3.6 and then jumped + to a non-gating `master`, leaving the entire 3.7 line -- the current + stable release -- tested by nothing. + +**`respawn_pane` reported the OLD command under load** + +`pane_current_command` lags `pane_pid` by a median of 14 ms while tmux +replaces the process, so the tool waits for the requested command's +basename before reporting. The 0.25s ceiling was called generous by an +order of magnitude — against a measurement taken on an idle machine. At +loadavg 30 it expired and `respawn_pane` returned `zsh` for a pane it +had just respawned as `sleep`. + +Widening one budget could not fix it: a command whose basename never +appears (`env FOO=1 sleep 5` reports `sleep`, not `env`) pays the +ceiling in full every time, so the same number is a ceiling for one +case and a bill for the other. Split in two — five seconds for the pid +to change, which exits the instant it does, then a quarter second for +the command to catch up. The wrapper case costs exactly what it did +before. + +**A cancellation test cost 60 seconds and checked the wrong process** + +Its stub `tmux` recorded `$$` and then ran `sleep 60`, so the pid it +asserted was reaped belonged to the SHELL, while the `sleep` that +actually held the stdout pipe was a child of it. Cancelling killed the +shell; the orphan kept the pipe open, so the test waited out the full +sleep. `exec sleep 60` makes the recorded pid the sleeping process: +0.02s instead of 60.03s, and the assertion now covers the process that +was surviving. + +**One structural guard replaces four audits** + +The async axis produced four separate defects on this branch, each +found by a different ad-hoc scan. The guard now reads the tree for all +of them at once: a known-blocking helper, a libtmux method on any +receiver, a synchronous `subprocess` call, or a `time.sleep`, called +INLINE from an async body. + +It distinguishes awaited from inline, so a helper converted to an async +bounded form stays on the list and a future synchronous reintroduction +is still caught. Nested `def`s are skipped deliberately -- those are +what gets handed to `asyncio.to_thread`, which is the fix rather than +the defect. + +The last two shapes were added after an audit found nothing, which is +the point: a scan that comes back clean is worth keeping only if it +runs again tomorrow. + +**Four timing probes measured the machine instead of the code** + +The parallel gate caught two flakes and a grep found two more of the +same shape. Each asserted something true about a fast machine: + +- `ticks >= 20` and `ticks >= 2` assume the event loop is scheduled. + At loadavg 28 the main thread went unscheduled for a whole 0.525s + window, so a starved loop and a blocked one both reported one tick. + The attempt is repeated now, which removes the false negative + without admitting a false positive: no number of attempts makes a + genuinely blocked loop tick twice. Verified by removing the + `to_thread` offload -- all three attempts return one tick and the + test fails. +- Two cancellation probes waited a fixed window for a tmux child to + spawn before cancelling. At loadavg 37 it had not, and the probe + failed its own precondition. They poll now, with the window derived + from the call's own budget so the two constants cannot drift apart. + +**An in-flight wait does not slow a concurrent tool call** + +Measured rather than asserted, because a test probe in this branch +turned out to violate exactly this property and went unnoticed for as +long as that test existed. + +At loadavg 26, a `show_option` issued while a wait is in progress, in +each of the three waiting shapes: + +| concurrent with | median | vs idle | +|---|---|---| +| nothing (baseline) | 15.7 ms | - | +| `wait_for_text` polling at a 10 ms interval | 16.1 ms | 1.03x | +| `wait_for_channel` blocked | 15.4 ms | 0.98x | +| `run_command` in flight | 15.7 ms | 1.00x | + +Unaffected, including at the minimum permitted poll interval. A static +check alone would not have settled it -- `asyncio.to_thread` still +blocks when the pool is exhausted -- so the measurement is under load +with the waits actually running. The only blocking-shaped calls inside +`async def` in the source are two `asyncio.sleep` poll intervals. + +**Every tmux behaviour this branch rests on is measured on all nine supported builds** + +3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7, 3.7a, 3.7b, 3.7c. None needs a version +gate, and the CI matrix has not seen any of this work. + +| behaviour | result | +|---|---| +| `wait-for -S` latches an unwaited signal | 9/9, 4-12 ms; a never-signalled channel still blocks | +| `--` terminates flag parsing for `display-message` | 9/9 | +| `list-clients -F` | 9/9 | +| `top` holds `alternate_on=0` | 9/9 | +| `set-hook` readable back at `-g`/`-t`/`-w`/`-gw` | 9/9 | +| narrowing a pane grows `history_size` | 9/9 | + +Everything this round ADDED was then swept the same way -- 45 checks +across `list_clients`, the new snapshot location fields, `break_pane`, +`join_pane` and `unset_environment`, on all nine builds, zero failures. +`break_pane` and `join_pane` compare the REPORTED location against +where the pane actually ended up, so a misreport fails without needing +a crash. + +One row of that sweep initially proved nothing, and the correction is +worth more than the result: every run used detached servers, so +`list_clients` returned zero clients on all nine versions and never +exercised its format parsing. Nine green rows for a path that did not +execute. Re-run with a real attached client on the oldest, middle and +newest builds, all six fields match exactly. An all-ok row means +"works" or "never ran", and only a fixture that can produce the +interesting case tells them apart. + +The latch is the one that had to be checked. `run_command` now always +waits its started channel first, so on a build that dropped an unwaited +signal a fast command would intermittently report as never-started -- +the double-execution lie, reintroduced per version. + +**A test carried an unasserted precondition on pane height** + +`test_capture_pane_truncates_tail_preserving` failed about three times +in eight full serial runs, never in twelve parallel gate runs, and +passed 6 of 6 alone and 3 of 3 with its own module. + +`max_lines=5` plus a truncation header means the assertion needs at +least SIX visible rows, and it asserted a literal `len(lines) == 6`. +That put it exactly on a boundary reachable by ordinary splits of a +24-row window -- measured, three splits leave panes of 6, 5, 5, 5, and +four splits leave 4, 4, 4, 4, 4: + +| pane height | returned | header | +|---|---|---| +| 6 | 6 | yes | +| 5 | 5 | **no** | +| 4 | 4 | **no** | + +Serial-only follows from that: under `-n auto` each xdist worker gets +its own tmux server, so a short pane cannot reach across; run serially, +the modules share one server in sequence. + +The cap is now derived from what the pane actually holds -- ask for one +fewer line than is there -- so the property is tested at any geometry +rather than at a number tuned to one. Verified at pane heights 24, 8, +6, 5, 4 and 3, including the three that used to fail. The assertion +also reports pane geometry when it does fail, because two reproduction +cycles were spent on a bare assertion that said only that the header +was absent. + +Both tests in that pair also checked a marker was visible and then +captured AGAIN to assert on, comparing two observations that were never +simultaneous -- and on a short pane the marker can leave the visible +region in between. They now assert on the capture that satisfied the +precondition. + +**The last occurrence was an interaction between two fixes in this +branch, and neither had it alone.** Deriving the cap as +`len(visible) - 1` is 0 on a one-line pane, and refusing a cap below 1 +-- the validation added for the truncation-header bug above -- then +rejects the call outright. Measured, two ordinary states reach one +visible line: a pane just after `clear`, and a fresh pane with nothing +on it. + +That is why it survived the single-observation fix: it was never about +observations. A capture of one line is a correct capture; the test was +constructing a call it was no longer allowed to make. + +The two-row requirement is now part of what the test WAITS for rather +than something it asserts afterwards, so the illegal call cannot be +constructed at all. Clamping the cap instead would have made the call +legal and left the claim wrong -- a one-line pane has nothing to +truncate, so no header can appear and an assertion expecting one still +fails. + +Two hypotheses were ruled out by measurement and are worth recording: +an attached 80x8 client does NOT shrink the pane, and a capture taken +while the alternate screen is active still returns the header. + +**A killed test run leaked a tmux daemon forever** + +Fixture finalizers do not run when pytest is `SIGKILL`ed, so an +interrupted run left a live tmux daemon and its socket behind +permanently. They accumulate: 119 live `libtmux_test*` servers spanning +three days were found on one development box. + +A clean run leaks none -- measured before and after, delta zero -- so +this is specifically about interrupted runs, of which a session +debugging flaky tests produces plenty. + +It is not only untidy. `list_servers` probes every live socket, so the +debris was also what made that tool spectacular rather than merely slow: +119 servers x ~12.7 ms is the 1517 ms measured there. + +`pytest_sessionstart` now reaps `libtmux_test*` sockets older than an +hour. The age gate is the load-bearing part: pytest-xdist workers all +start at once, so an unconditional reaper in one worker would kill the +servers another worker had just created. An hour is far longer than the +two-minute suite and far shorter than debris survives. + +**The suite is green in CI while two to five tests fail per parallel run** + +Measured, and it is the finding that supersedes several smaller ones in +this branch's history. + +Run the suite as CI does — `pytest -n auto` — but with `--reruns` off, +and **every run fails**. Two independent sets of runs agree: + +``` +run 1 2 3 4 5 +A 1 5 2 3 3 failures +B 6 4 1 - - failures +``` + +A different set each time, well over a dozen distinct tests in the +union. Most send keys to a real `zsh` and wait inside a fixed budget; +under `-n auto` the tmux servers and shell startups compete and budgets +tuned on an idle machine expire. + +**Scope the fix by the pattern, not by the filename.** Eleven sampled +runs put all but one failure in `tests/test_pane_tools.py`, which +invites narrowing the work to that module. Counting the at-risk pattern +instead — drives a real shell *and* waits on output — finds it in +several modules, with `test_pane_tools.py` holding roughly 75-85% of +the wait sites. The spread is itself instructive: a pattern that counts +`wait_for_text` calls inflates that module, because `wait_for_text` is +the tool *under test* there, so the metric measures something adjacent +to the claim in precisely the place the adjacency is greatest. +`retry_until` alone is the cleaner proxy. Its dominance in every sample +is a density effect, not exclusivity, and a thin tail elsewhere is +exactly what a single observed failure in `tests/test_history.py` +represents. Sampling reads a distribution's mode as its support; the +density count settles in one pass what eleven runs could not. + +Run it in the default configuration and the same suite reports +`968 passed, 6 skipped, **7 rerun**` — green. The rerun count is the +only visible trace, and it is easy to read past. + +Three separate per-test explanations were proposed and measured away +during this branch before the shape became clear: a shared pane shrunk +by another test's split (impossible — the fixtures are function-scoped), +a retry budget too tight in the abstract (the marker needs 0.24 s +against 2 s), and test ordering (survives randomization in isolation). +Each was a local explanation for a global property, and each looked +plausible because a single failing test is exactly what a global +property looks like from inside one test. + +The configurations are why it took so long to see: fixed-order with +reruns on, random-order with reruns off but serial, and CI's parallel +with reruns on are all blind to it. Only parallel-with-reruns-off +shows it. + +One earlier change in this branch that moved a test to its own window +on the strength of the shared-pane theory has been reverted, since it +addressed a mechanism that cannot occur. + +**Fixed, and mostly not by adjusting budgets** + +A fresh six-run sample confirmed the scoping criterion above: the union +now reaches `tests/test_history.py`, so the work is scoped by the +pattern — drives a real shell AND waits — and not by the file that +happens to hold most of it. + +The dominant cause is not a budget. Twelve test helpers slept and +claimed to synchronise: `_emit_after_baseline`'s docstring said "once +the wait has armed" and its body was `await asyncio.sleep(0.2)`. Five +more said "let wait_for_text capture its baseline first" above a +`sleep(0.1)`. Losing that race is not a slow pass, it is a permanent +failure: `wait_for_text` filters each poll against the row content +captured at entry, so a marker emitted before the baseline is inside +that set and can never match afterwards. One such failure burned a +**20 second** budget — no budget would have saved it, which is exactly +what made "raise the timeouts" the wrong first instinct. + +Those twelve sites now await an event set on the wait's *second* +`_bounded_capture`. The first of those calls IS the entry capture, and +the second is issued only after its rows have been stored, so arming is +complete by the time the event fires. The helper docstrings became +true rather than aspirational. + +One test resisted that fix and needed restructuring instead: the +history-limit risk-band test raced a 200-line burst against a 2 s wait +to push `history_size` into the top 10% of `history-limit`. It now +fills history into the band *before* the wait starts, so the warning +fires on the first poll. + +The genuinely time-based ones were then raised together. Every +`retry_until` in the suite is `raises=True` — a positive wait — and for +those a larger budget costs nothing when the test passes, because the +call returns as soon as its condition holds. It only lengthens +time-to-fail. So all 56 sites under 10 s went to 10 s at once rather +than one per sampled run. `run_command`'s status-after-shell-state +test went from 2 s to 10 s for the same reason: its subject is the +reported status, not the latency. + +`send_keys_batch`'s timeout fixture was a third thing again. Its sends +are monkeypatched to return instantly, so the 0.05 s budget bounded +nothing but Python overhead — and under load that overhead pre-empted +the injected `TimeoutExpired` the test exists to exercise, so the test +failed while measuring the wrong mechanism. +One budget is deliberately left small and now says so in the code: +`test_run_command_reports_unclamped_timeout` passes a timeout that it +then asserts is echoed back verbatim, so widening it breaks the +assertion. The sweep above caught it, which is the argument for +sweeping rather than editing constants by hand. + +A follow-up pass caught the cost of getting that rule slightly wrong. +Raising a budget is free only when the call returns as soon as its +condition holds. Three sites are the opposite -- they spend the whole +budget on purpose, to prove something does NOT happen -- and raising +those turned a ceiling into a bill: + +| site | before | after | +|---|---|---| +| `wait_for_text` case `silent_pane_still_times_out` | 20.0 s | 1.0 s | +| `pipe_pane` "the file must not grow" | 10.0 s | 1.0 s | +| `wait_for_text` tail-cap (pattern never appears) | 6.0 s | 3.0 s | + +The `pipe_pane` one is the instructive shape: it is a +`retry_until(..., raises=True)` *inside* `pytest.raises(WaitTimeout)`, +so it reads as a positive wait and behaves as a negative one. Serial +suite time went from 139 s to 117 s, and the slowest single test from +20.0 s to 3.0 s. + +Measured before and after, `pytest -n auto --reruns 0`, same box: + +| | runs failing | failures per run | loadavg at start | +|---|---|---|---| +| before | 6 of 6 | 3, 2, 2, 1, 1, 5 | 13 - 33 | +| after | **0 of 6** | 0, 0, 0, 0, 0, 0 | 8 - 36 | + +The final run randomises test order, an axis the fixed-order runs never +exercise. The "after" rows are the less favourable comparison, not the +more: the box was shared throughout with a second agent driving tmux. + +Two defects were caught by this gate and by nothing else, both of them +in work done during this branch. A `run_command` guard refused a +LEGITIMATE command at loadavg 31, and a `list_servers` test failed only +under `pytest-xdist` because a UNIX socket path is capped at 108 bytes +and the worker and test-name components push `tmp_path` over it. Every +serial run passed both. + +**`mcp_swap.py` covers opencode and pi** + +`use-local`, `status`, `revert`, `doctor` and `detect` now reach two more agent +CLIs. + +opencode is the first config the script edits that is not plain JSON or TOML. +Its `$XDG_CONFIG_HOME/opencode/opencode.jsonc` is JSONC, its server map hangs +off a top-level `mcp` key rather than `mcpServers`, and one entry packs argv +into a single `command` array with its environment table spelled +`environment`. Getting the shape wrong is not a soft failure there: a scalar +`command` is a decode error that stops opencode starting, and an `env` key is +dropped without a word. Comments survive a swap, including one written directly +above the `command` it explains. + +pi ships no MCP client — its README says so outright, and the released build +contains no MCP code. `~/.pi/agent/mcp.json` is read by the third-party +`pi-mcp-adapter` extension, so a swap written there takes effect only once that +package is installed. `detect` says so rather than reporting a swap that cannot +do anything. + +**JSONC is edited rather than reserialized** + +The JSON writer rebuilds the whole document, which for a commented file would +mean deleting every comment in it. JSONC values now come from stdlib `json` +after comments and trailing commas are blanked in place, and writes are applied +as text splices, so every byte outside a replaced value is untouched. The +obvious dependency was measured and rejected: `json-five` round-trips comments, +but raises on the valid JSON string `"C:\\x"` and silently decodes a literal +`\u0041` to `"A"`. + +**Per-CLI behavior is declared, not branched** + +`CLIInfo` gained `container` (the key path to the server map) and `dialect` (the +entry shape) alongside `fmt`. The four `cli in (...)` membership tuples that +`get_server`, `set_server`, `delete_server` and `_all_server_specs` each carried +are gone. Two of them ended in a bare `else` that fell through to the TOML key, +so a CLI registered but forgotten in one tuple reported "no entry" instead of +failing; the other two raised `AssertionError`, which the caller did not catch. +A non-mapping at a container key now raises a `RuntimeError` naming the path for +every CLI, not just Claude. `scripts/README.md`'s extension guide described +three branch sites when there were four; it now describes the fields instead. + +## libtmux-mcp 0.1.0a20 (2026-08-09) + +libtmux-mcp 0.1.0a20 changes no tool behavior. `scripts/mcp_swap.py` gains `use-local --pr N`, which points installed agent CLIs at a pull request without a checkout and verifies the server before it rewrites any configuration, and its edits now preserve unrelated config text, file permissions, and symlink targets. ruff's curated default rule set is enabled behind a `ruff>=0.16.0` floor, taking the project from 351 enabled rules to 565 and fixing what that surfaced, and the CI workflow actions move to their current majors. In the documentation, the dataclass identifying an MCP caller describes each of its fields instead of reaching the API reference as "Alias for field number 0". + ### Documentation #### opencode joins the install picker @@ -77,6 +3237,14 @@ or as a bare name carrying only its type. ### Development +**Safer pull-request testing with `mcp_swap.py`** + +`use-local --pr N` points installed agent CLIs at a pull request without a +checkout and verifies the server before changing their configuration. Swaps +preserve unrelated config text, file permissions, and symlink targets, while +retaining the original recovery data across failed or concurrent updates. +(#115) + #### CI actions updated to current majors Workflow actions moved to their current major releases: `actions/checkout` v7, diff --git a/MIGRATION b/MIGRATION index 93509ec9..d3f1a8b2 100644 --- a/MIGRATION +++ b/MIGRATION @@ -19,6 +19,108 @@ for the full release log. [tracker]: https://github.com/tmux-python/libtmux-mcp/discussions ``` +## libtmux-mcp 0.1.x (unreleased) + +### A name or title argument is stored literally + +`rename_window`, `rename_session`, `set_pane_title`, `create_window` and +`create_session` passed your name straight to tmux, which expands `#{...}` in +it. A name containing one silently became something else. + +Nothing changes for an ordinary name. If you were relying on the expansion, +ask for the value and pass the result: + +#### Before + +```json +{"tool": "rename_window", "arguments": {"new_name": "#{pane_current_path}"}} +``` + +That stored `/home/you/src`, not the text you wrote. + +#### After + +```json +{"tool": "display_message", "arguments": {"format_string": "#{pane_current_path}"}} +``` + +```json +{"tool": "rename_window", "arguments": {"new_name": "/home/you/src"}} +``` + +`display_message` is the one tool that still expands formats, by design. For a +window that should track its own state, tmux's own `automatic-rename-format` +option is a better fit than renaming on a timer. + +The same applies to `start_directory` on `create_session`, `create_window`, +`split_window` and `respawn_pane`: a `#` in the path was expanded, the result +did not exist, and the shell silently started in `$HOME` instead. + +### An option name containing `#` is refused + +`set_option` and `show_option` now raise for an option NAME containing a `#`, +rather than addressing a different option than the one you named. + +Option *values* are unaffected and always were. Only the name is checked, and +only user options (`@name`) can contain a `#` in the first place, so an +ordinary call is unchanged. If you need one, use `run_command` with a raw tmux +command line. + +### Tools that deliver input require an explicit target + +`send_keys`, `send_keys_batch`, `paste_text`, `paste_buffer` and +`run_command` accepted no target at all and picked a pane themselves. They now +refuse, naming the four ways to say where. + +Nothing changes for a call that already names a pane. A call that does not gets +an error rather than a guess, and the fix is one field. + +#### Before + +```json +{"tool": "send_keys", "arguments": {"keys": "make test"}} +``` + +#### After + +```json +{"tool": "send_keys", "arguments": {"pane_id": "%1", "keys": "make test"}} +``` + +`create_session`, `create_window` and `split_window` return the new pane's id +(`active_pane_id` / `pane_id`), so an agent that made the pane already has the +value. One that attached to a server it did not create can call `list_panes`. + +For `send_keys_batch` the rule is per operation: one untargeted entry among +targeted ones fails alone rather than being sent somewhere arbitrary. + +### An untargeted READ resolves to the oldest object, not the first listed + +Reads still default. What changed is which object they pick, and that the +option and hook tools now pick the same one as everything else. + +The old default was the first LISTED object, and tmux lists sessions BY NAME — +so `rename_session` silently moved where a later untargeted call landed. +`show_option`, `show_hooks` and `show_hook` did not use that rule at all: they +omitted tmux's `-t` and let tmux resolve by `activity_time`, which moves +whenever a pane produces output. + +Both are now the oldest surviving object by tmux id, which nothing can move. +`show_hooks` and `show_hook` join `show_option` in reporting `resolved_target`, +so an untargeted read says which object answered. + +If you relied on an untargeted read describing the most recently active +session, name it — the destination is no longer activity-sensitive. + +### Prompt arguments naming an object are validated + +`pane_id` on `run_and_wait`, `diagnose_failing_pane` and `interrupt_gracefully` +must match `%N`. `session_name` on `build_dev_workspace` may not be empty or +contain control characters, `:` or `.`. + +Free-text arguments (`command`, `log_command`) are unchanged and still carry +anything. + ## libtmux-mcp 0.1.0a19 (2026-07-25) ### `wait_for_text` takes `patterns`, and `wait_for_content_change` is gone diff --git a/docs/installation.md b/docs/installation.md index 68059a01..73f973be 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,9 +5,43 @@ ## Requirements - Python 3.10+ -- tmux >= 3.2a +- tmux >= 3.2a — and see {ref}`client-server-versions` if the tmux running + this server is older than the tmux that started the session you point it at - [uv](https://github.com/astral-sh/uv) ([install](https://docs.astral.sh/uv/getting-started/installation/)) or [pipx](https://github.com/pypa/pipx) ([install](https://pipx.pypa.io/stable/installation/)) — for running without a persistent install +(client-server-versions)= + +## Client and server tmux versions + +The floor above is the tmux **this server runs**. There is a second +constraint, because tmux sockets outlive the binary that made them: a +client can only talk to a server whose protocol it understands. + +Measured across every pair of the nine supported versions — 81 +combinations, each checked against the tmux binary directly and then +through this server: + + server 3.6 and newer, client 3.5 and older unreachable + every other pair fine + +A clean rectangle, and one-directional: 3.6+ clients read every server +including old ones, and everything 3.5-and-older is mutually compatible +both ways. + +You hit this when a system tmux upgrade leaves a session running under +the old binary, or when this server runs an older tmux than the one +that created the socket. It surfaces honestly rather than as an empty +result: + + tmux server exists but could not be queried: server exited + unexpectedly. ... + +That is the start of the message, not all of it — the rest explains why +reporting no sessions would be wrong. Grep for `could not be queried`. + +If you see that, compare `tmux -V` against the version that started the +session and point `LIBTMUX_TMUX_BIN` at the matching binary. + ## Run without installing No persistent install needed — run directly with a package executor: diff --git a/docs/redirects.txt b/docs/redirects.txt index 2c28f11b..650e2a58 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -10,7 +10,7 @@ "reference/api/resources" "resources" "tools/prompts" "prompts" "tools/resources" "resources" -"api/utils" "reference/api/utils" +"api/utils" "reference/api/internals" "architecture" "topics/architecture" "concepts" "topics/concepts" "safety" "topics/safety" diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md index bd899006..d91836c9 100644 --- a/docs/reference/api/index.md +++ b/docs/reference/api/index.md @@ -29,10 +29,10 @@ Pydantic models for requests and responses. Safety-tier enforcement and request hooks. ::: -:::{grid-item-card} Utils -:link: utils +:::{grid-item-card} Internals +:link: internals :link-type: doc -Shared helpers and utilities. +Errors, safety tiers, tmux execution, caching, resolution and filters. ::: :::: @@ -44,5 +44,5 @@ server tools models middleware -utils +internals ``` diff --git a/docs/reference/api/internals.md b/docs/reference/api/internals.md new file mode 100644 index 00000000..6d34f050 --- /dev/null +++ b/docs/reference/api/internals.md @@ -0,0 +1,88 @@ +# Internals + +Private modules the tools are built from, in dependency order: each one +depends only on those above it. + +## Errors + +```{eval-rst} +.. automodule:: libtmux_mcp._errors + :members: + :undoc-members: + :show-inheritance: +``` + +## Safety tiers + +```{eval-rst} +.. automodule:: libtmux_mcp._safety + :members: + :undoc-members: +``` + +## Argument guards + +```{eval-rst} +.. automodule:: libtmux_mcp._guards + :members: + :undoc-members: +``` + +## tmux execution + +```{eval-rst} +.. automodule:: libtmux_mcp._exec + :members: + :undoc-members: + :show-inheritance: +``` + +## Caller identity + +```{eval-rst} +.. automodule:: libtmux_mcp._caller + :members: + :undoc-members: + :show-inheritance: +``` + +## Server cache + +```{eval-rst} +.. automodule:: libtmux_mcp._servers + :members: + :undoc-members: +``` + +## Object resolution + +```{eval-rst} +.. automodule:: libtmux_mcp._resolve + :members: + :undoc-members: +``` + +## Pane state + +```{eval-rst} +.. automodule:: libtmux_mcp._pane_state + :members: + :undoc-members: + :show-inheritance: +``` + +## Filters + +```{eval-rst} +.. automodule:: libtmux_mcp._filters + :members: + :undoc-members: +``` + +## Serialization + +```{eval-rst} +.. automodule:: libtmux_mcp._serialize + :members: + :undoc-members: +``` diff --git a/docs/reference/api/utils.md b/docs/reference/api/utils.md deleted file mode 100644 index f4b9fe56..00000000 --- a/docs/reference/api/utils.md +++ /dev/null @@ -1,8 +0,0 @@ -# Utilities - -```{eval-rst} -.. automodule:: libtmux_mcp._utils - :members: - :undoc-members: - :show-inheritance: -``` diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index 2f857575..5b5ed674 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -20,6 +20,31 @@ | >= 3.2a | Supported | | < 3.2a | Not supported (libtmux requirement) | +Every tool behaves identically across that range, with one exception. + +### `copy_selection` requires tmux >= 3.4 + +On tmux 3.2a and 3.3a, `copy-selection` kills the tmux **server** — +and every session on it — rather than failing. Four conditions have to +hold together, and all four hold in a default install: + +| condition | safe alternative | +|---|---| +| tmux 3.2a or 3.3a | 3.4 and later are unaffected | +| a client is attached | detached servers survive | +| `set-clipboard` is on | the default is `external` on 3.2a..3.7c | +| the terminal advertises clipboard support | `xterm-256color` crashes; `screen-256color` does not | + +That conjunction is exactly what the tool is for — reading a person's +selection means an attached client, in a real terminal, at default +settings — so the surviving configurations are the ones with nobody in +them. {tooliconl}`copy-selection` refuses below 3.4 and names the +version. Use {tooliconl}`capture-pane` there instead. + +From tmux 3.6 the copy also passes `-C`, so reading a selection does not +overwrite the user's system clipboard. On 3.4 and 3.5 that flag does not +exist and the copy does reach the clipboard. + ## Dependencies | Package | Required version | diff --git a/docs/tools/buffer/delete-buffer.md b/docs/tools/buffer/delete-buffer.md index d9cc8994..24aa19c9 100644 --- a/docs/tools/buffer/delete-buffer.md +++ b/docs/tools/buffer/delete-buffer.md @@ -10,7 +10,7 @@ across MCP restarts. **Side effects:** Removes the named buffer from the tmux server. Subsequent {tooliconl}`paste-buffer` calls against the deleted name -return an {exc}`~libtmux_mcp._utils.ExpectedToolError`. +return an {exc}`~libtmux_mcp._errors.ExpectedToolError`. ```{fastmcp-tool-input} buffer_tools.delete_buffer ``` diff --git a/docs/tools/hook/show-hook.md b/docs/tools/hook/show-hook.md index 33fa3af7..77d5d4f4 100644 --- a/docs/tools/hook/show-hook.md +++ b/docs/tools/hook/show-hook.md @@ -5,7 +5,7 @@ **Use when** you know which hook you want to inspect by name. Returns empty when the hook is unset; raises an -{exc}`~libtmux_mcp._utils.ExpectedToolError` for +{exc}`~libtmux_mcp._errors.ExpectedToolError` for unknown hook names (typos, wrong scope) so input mistakes don't masquerade as "nothing configured". diff --git a/docs/tools/index.md b/docs/tools/index.md index 6bde2301..9c1202a7 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -47,6 +47,7 @@ leave socket selection inside each nested tool's arguments. See **Scrollback / copy mode?** - Enter copy mode → {tool}`enter-copy-mode` +- Read a person's copy-mode selection → {tool}`copy-selection` - Exit copy mode → {tool}`exit-copy-mode` - Log output to file → {tool}`pipe-pane` diff --git a/docs/tools/pane/capture-since.md b/docs/tools/pane/capture-since.md index 5e1b95e7..24d0a2ab 100644 --- a/docs/tools/pane/capture-since.md +++ b/docs/tools/pane/capture-since.md @@ -61,7 +61,7 @@ Read only content since that cursor: The cursor carries the original pane id, so the follow-up call does not need `pane_id`. If you pass both, they must match; a cursor for another pane raises -an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of silently reading the +an {exc}`~libtmux_mcp._errors.ExpectedToolError` instead of silently reading the wrong process. If nothing new was written after the cursor, `lines` is empty and the response @@ -74,7 +74,7 @@ needed to compute an exact delta. In that case, `lines` is a conservative current visible capture and the response includes a fresh cursor. Pane lifecycle is part of the cursor contract. If the pane dies or is respawned, -the call raises an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of +the call raises an {exc}`~libtmux_mcp._errors.ExpectedToolError` instead of reading from a different process that reused the same pane id. `truncated`, `truncated_lines`, and `truncated_bytes` are structured metadata. diff --git a/docs/tools/pane/copy-selection.md b/docs/tools/pane/copy-selection.md new file mode 100644 index 00000000..0b561536 --- /dev/null +++ b/docs/tools/pane/copy-selection.md @@ -0,0 +1,66 @@ +# Copy selection + +```{fastmcp-tool} pane_tools.copy_selection +``` + +**Requires tmux >= 3.4.** On 3.2a and 3.3a `copy-selection` kills the +tmux server and every session on it; the tool refuses there rather than +risking it. See {doc}`/reference/compatibility`. + +**Use when** a person attached to the session has highlighted something +and you are being asked about *that highlight* rather than about the +pane. This is the one case {tooliconl}`capture-pane` cannot reach: it +returns pane text, not the region a human picked out of it. + +**Reads what is selected right now, not what was just copied.** A person +who pressed `Enter` or `y` has already left copy mode, and that text is +in tmux's own buffer, which this server does not read — see +{doc}`/topics/safety`. Most key bindings copy *and* cancel, so that is +the common human ending; the answer is to ask them to select again. + +The selection is durable enough to act on: it survives idle time, cursor +movement and further process output. Only leaving copy mode clears it. + +**Side effects:** allocates a new MCP-namespaced buffer. The selection +itself is left intact, so reading it does not disturb the person who +made it. Pass the returned `buffer_name` to {tooliconl}`paste-buffer` to +drop the selection into another pane, or {tooliconl}`delete-buffer` when +done. + +**Example:** + +```json +{ + "tool": "copy_selection", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "buffer_name": "libtmux_mcp_4f3c2a1b9d8e7f6a5b4c3d2e1f0a9b8c_buf0", + "content": "ERROR: connection refused\n at pool.acquire (db.js:42)", + "content_truncated": false, + "content_truncated_lines": 0 +} +``` + +Three refusals, all loud on purpose: + +```text +copy_selection requires tmux 3.4 or newer (this server runs 3.2a) +pane %0 is not in copy mode +pane %0 is in copy mode but nothing is selected +``` + +The second matters more than it looks. tmux exits 0 for a copy with +nothing selected and creates no buffer at all, so without that check the +tool would report success and hand back a buffer name that does not +exist. + +```{fastmcp-tool-input} pane_tools.copy_selection +``` diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md index 85e8a75a..e86b186c 100644 --- a/docs/tools/pane/index.md +++ b/docs/tools/pane/index.md @@ -77,6 +77,10 @@ Resize a pane. Enter tmux copy mode for scrollback navigation. ::: +:::{grid-item-card} {tooliconl}`copy-selection` +Read the text a person has selected in copy mode. +::: + :::{grid-item-card} {tooliconl}`exit-copy-mode` Exit copy mode. ::: @@ -125,6 +129,7 @@ set-pane-title clear-pane resize-pane enter-copy-mode +copy-selection exit-copy-mode wait-for-text wait-for-channel diff --git a/docs/tools/pane/respawn-pane.md b/docs/tools/pane/respawn-pane.md index 7142910f..f994cac7 100644 --- a/docs/tools/pane/respawn-pane.md +++ b/docs/tools/pane/respawn-pane.md @@ -71,7 +71,7 @@ time). } ``` -Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, sha256_prefix}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details. +Mapping input keeps the keys visible in the audit log but replaces each `environment` *value* with a `{len, digest}` digest. A JSON object string is redacted as one scalar digest, so its keys are not retained in the audit record. Values may still appear briefly in the OS process table while tmux spawns the new process — see {ref}`safety` for details. Response ({class}`~libtmux_mcp.models.PaneInfo`): diff --git a/docs/tools/pane/signal-channel.md b/docs/tools/pane/signal-channel.md index 02d456c8..44d72a4a 100644 --- a/docs/tools/pane/signal-channel.md +++ b/docs/tools/pane/signal-channel.md @@ -5,8 +5,28 @@ **Use when** you need to wake a blocked {tooliconl}`wait-for-channel` caller from a different MCP context (e.g. when a long-running task in -one pane completes and another pane should proceed). Signalling an -unwaited channel is a successful no-op — safe to call defensively. +one pane completes and another pane should proceed). + +**Signal exactly once per channel.** tmux *latches* a signal nobody is +waiting for, which is what makes this better than polling — signal +first, wait later, and "did it finish?" becomes a question about the +past. A **second** signal on a latched channel with no waiter destroys +the channel, latch and all, and the next wait blocks to its ceiling. It +toggles rather than saturating: + +| signals, then wait | result | +|---|---| +| 1 | returns in 0.04s | +| 2 | **blocks** — latch cleared | +| 3 | returns in 0.03s | + +The habit that breaks it is the careful one: a `wait-for -S done` at the +end of the command *and* another in a cleanup or trap. Put it in exactly +one place. + +This is tmux's own behaviour and the server does not paper over it — +reading the latch first is a race, and there is no non-destructive way +to ask. **Side effects:** Wakes any clients blocked on the named channel. Doesn't allocate or persist state. diff --git a/docs/tools/pane/wait-for-channel.md b/docs/tools/pane/wait-for-channel.md index 82cc8a22..cd847044 100644 --- a/docs/tools/pane/wait-for-channel.md +++ b/docs/tools/pane/wait-for-channel.md @@ -39,7 +39,7 @@ safer than edge-triggered signalling for fragile commands. **Side effects:** Blocks the call up to `timeout` seconds (default 30). Mandatory subprocess timeout — a crashed signaller raises an -{exc}`~libtmux_mcp._utils.ExpectedToolError` rather than blocking +{exc}`~libtmux_mcp._errors.ExpectedToolError` rather than blocking indefinitely. ```{fastmcp-tool-input} wait_for_tools.wait_for_channel diff --git a/docs/topics/architecture.md b/docs/topics/architecture.md index c24c0178..81eb509a 100644 --- a/docs/topics/architecture.md +++ b/docs/topics/architecture.md @@ -10,24 +10,49 @@ For contributors who need to understand the codebase internals. src/libtmux_mcp/ __init__.py # Entry point: main() __main__.py # python -m libtmux_mcp support - server.py # FastMCP instance and configuration - _utils.py # Server caching, resolvers, serializers, error handling + server.py # FastMCP instance, safety tier, instructions budget models.py # Pydantic output models middleware.py # Safety, audit, retry, and error-result middleware + _errors.py # ExpectedToolError and the tool-boundary decorators + _safety.py # Safety tiers and the MCP annotations publishing them + _guards.py # Argument preconditions refused before tmux is reached + _exec.py # tmux argv, wall-clock bounds, exec failures + _caller.py # Which pane the caller is talking to us from + _servers.py # Server cache and the liveness it is gated on + _resolve.py # Ids and names to live libtmux objects + _pane_state.py # Pane grid and lifecycle state in one round trip + _filters.py # Django-style field lookups over QueryLists + _serialize.py # libtmux objects to pydantic models + _tmux_proc.py # Cancellable, bounded tmux subprocess (see below) + _bounded_io.py # Bounded tmux reads shared by the async tools + _patterns.py # Caller-regex screening + _progress.py # Progress ticker for waits with no poll loop + _history.py # Shell-history suppression + _wait_policy.py # Wait ceiling resolution tools/ - batch_tools.py # call_readonly_tools_batch, call_mutating_tools_batch, call_destructive_tools_batch - server_tools.py # list_servers, list_sessions, create_session, kill_server, get_server_info - session_tools.py # list_windows, create_window, rename_session, kill_session - window_tools.py # list_panes, split_window, rename_window, kill_window, select_layout, resize_window - pane_tools.py # run_command, send_keys, send_keys_batch, capture_pane, capture_since, snapshot_pane, search_panes, wait_for_text + batch_tools.py # call_{readonly,mutating,destructive}_tools_batch + server_tools.py # list_servers, list_sessions, create_session, ... + session_tools.py # list_windows, create_window, rename_session, ... + window_tools.py # list_panes, split_window, break_pane, join_pane, ... buffer_tools.py # load_buffer, paste_buffer, show_buffer, delete_buffer hook_tools.py # show_hooks, show_hook option_tools.py # show_option, set_option - env_tools.py # show_environment, set_environment - resources/ - hierarchy.py # tmux:// URI resources + env_tools.py # show_environment, set_environment, unset_environment + wait_for_tools.py # wait_for_channel, signal_channel + pane_tools/ # split by operation kind, not one file per tool + io.py # send_keys, paste_text, run_command, capture_pane + wait.py # wait_for_text + capture_since.py # incremental capture and its cursor + state.py # the one pane-state read every tool shares + meta.py # get_pane_info, snapshot_pane, display_message + layout.py # select_pane, resize_pane, swap_pane + lifecycle.py # respawn_pane, kill_pane + copy_mode.py # enter_copy_mode, exit_copy_mode + pipe.py # pipe_pane + search.py # search_panes, find_pane_by_position + prompts/recipes.py # The four workflow prompts + resources/hierarchy.py # tmux:// URI resources ``` - ## Request flow Middleware wraps tool calls outermost-first (full ordering rationale in @@ -63,19 +88,96 @@ Each tool module defines a `register(mcp)` function that registers tools with me ### Server caching -{mod}`libtmux_mcp._utils` maintains a thread-safe cache keyed by +{mod}`libtmux_mcp._servers` maintains a thread-safe cache keyed by `(socket_name, socket_path, tmux_bin)`. Dead servers are evicted on access via {meth}`libtmux.Server.is_alive` checks. ### Object resolution -Tools use resolver functions ({func}`~libtmux_mcp._utils._resolve_session`, -{func}`~libtmux_mcp._utils._resolve_window`, and -{func}`~libtmux_mcp._utils._resolve_pane`) that accept multiple +Tools use resolver functions ({func}`~libtmux_mcp._resolve._resolve_session`, +{func}`~libtmux_mcp._resolve._resolve_window`, and +{func}`~libtmux_mcp._resolve._resolve_pane`) that accept multiple targeting parameters and resolve to the correct {external+libtmux:doc}`libtmux ` object. Resolution follows a priority chain: direct ID → name lookup → error. +### Reaching tmux from an async tool + +Every async tool goes through `_tmux_proc`, which owns a tmux +subprocess it can kill. Neither of the obvious alternatives works: + +- **Calling libtmux inline** blocks the event loop. It reaches tmux + through `Popen.communicate()` with no timeout, so every other + in-flight call waits, and against a server that has stopped + answering, waits indefinitely. +- **Wrapping it in `asyncio.to_thread`** frees the loop and creates a + worse failure. The coroutine takes the cancellation immediately while + the worker stays blocked, and + `concurrent.futures.thread._python_exit` joins pool workers untimed + at shutdown — so one wedged tmux takes process exit and Ctrl-C with + it. The loop keeps ticking throughout, which is why no + loop-blocking test can see this. + +`asyncio.to_thread` stays correct for **bounded** work. `_run_send_keys` +runs each argv under a timeout, so its worker always returns. The +hazard is the untimed call, not the thread. + +Seeing the failure at all needs a socket that answers its FIRST +connection and stalls the rest: one that never answers is caught by the +bounded liveness probe before the unbounded call is reached, so a +fixture built the obvious way comes back confidently clean. + +`tests/test_pane_tools.py` enforces the rule structurally rather than +by measurement — it reads the tree for a blocking call made inline from +an async body. + +### The bound under the synchronous tools + +Most tools are plain `def`, which FastMCP runs on a worker thread. That +keeps the event loop alive but does nothing about the wait: libtmux +still reaches tmux through an untimed `Popen.communicate()`, so a tool +that touched a stalled server never returned at all. `break_pane` makes +eleven round trips and was measured still running at 150 seconds. + +The liveness probe does not help here. It bounds the FIRST round trip +and nothing after it, so a socket that answers the probe and stalls +afterwards walks straight past it. + +The bound is installed at `tmux_cmd` itself, in `_exec`, and rebound +into every libtmux module that constructs one. `Server.cmd` is not the +only funnel — `neo.fetch_objs` builds a `tmux_cmd` directly and is the +engine behind `Window.panes` and `Session.windows`, so bounding +`Server.cmd` alone leaves the busiest path unbounded. A test AST-walks +the installed libtmux and fails if a call site appears outside the +bound set, because a rebind that stops applying does so silently. + +Why it matters more than one slow call: a hung call never returned its +worker, and cancelling the coroutine did not interrupt it. Forty +accumulated hung calls — concurrently, or one at a time with a cancel +between each — exhaust anyio's default thread limiter, and the server +then stops answering everything, including healthy sockets. Forty is +reached by an agent behaving correctly: call, give up, retry. The bound +returns the worker, and `subprocess.run` kills and reaps the tmux +client, so neither threads nor processes accumulate. Measured at 64 +concurrent stalled calls — 60% past the pool limit that used to be +fatal — every one returns a bounded error and no client is left behind. + +Two consequences of a bounded pool, both real: + +- **Unrelated calls can wait for one bound.** While stalled calls hold + every worker, a call to a perfectly healthy socket waits for a worker + to come free, and the first one frees when the first stalled call + times out. Measured at 4.2s for the first such call, then ~25ms for + every one after. So a stalled tmux server costs other sockets up to + one timeout of latency, not zero. +- **Cancellation does not reap; the bound does.** Cancelling the request + cannot interrupt a thread already inside `communicate()`, so the tmux + client dies when the bound fires, not when the caller gives up. + Measured: 16 cancelled calls still had 16 clients at +4s and zero at + +7s. An agent that cancels and retries immediately still stacks — but + the window is one bound wide instead of unbounded, which is the whole + difference between annoying and fatal. + ### Safety middleware {class}`~libtmux_mcp.middleware.SafetyMiddleware` implements @@ -88,7 +190,7 @@ is invoked. Three boundaries split the work: -1. **Tool classification** — the {func}`~libtmux_mcp._utils.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._utils.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e`, which is what lets {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` match transient {exc}`~libtmux.exc.LibTmuxException` causes. +1. **Tool classification** — the {func}`~libtmux_mcp._errors.handle_tool_errors` decorator wraps tool functions, mapping {external+libtmux:doc}`libtmux ` exceptions to {exc}`~libtmux_mcp._errors.ExpectedToolError` (agent-correctable: unknown ids, invalid arguments, transient tmux errors; logged at WARNING) or FastMCP tool errors (operator faults and unexpected bugs; logged at ERROR). The raise chains the original exception via `from e`, which is what lets {class}`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` match transient {exc}`~libtmux.exc.LibTmuxException` causes. 2. **Schema classification** — FastMCP validates tool arguments before tool code runs, so [Pydantic](https://docs.pydantic.dev/) validation failures never reach the decorator. {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` classifies those schema-validation errors as expected, agent-correctable WARNINGs before converting them. 3. **Conversion** — {class}`~libtmux_mcp.middleware.ToolErrorResultMiddleware` catches the exception once it has cleared the audit/retry/safety trio and returns an error `ToolResult` carrying the message exactly as raised, plus a `_meta` payload (`error_type`, `expected`, and an optional agent-facing `suggestion` for recovery hints such as discovery tools or rejected-argument fixes). diff --git a/docs/topics/gotchas.md b/docs/topics/gotchas.md index 9bc678e4..25dbd9f3 100644 --- a/docs/topics/gotchas.md +++ b/docs/topics/gotchas.md @@ -74,6 +74,70 @@ Pane IDs like `%0`, `%5`, `%12` are unique across all sessions and windows withi 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 {tooliconl}`list-panes`. +## Omitting the target means two different things + +Tools that DELIVER input — {tooliconl}`send-keys`, {tooliconl}`send-keys-batch`, +{tooliconl}`paste-text`, {tooliconl}`paste-buffer`, {tooliconl}`run-command` — +require a target and refuse without one. There is no safe default for a call +that types something: the pane it would pick is unrelated to what the call is +for, and the destination reaches the caller only after the keystrokes have +landed. + +Reads still default, to the **oldest** surviving object by tmux id. Not the +first listed — tmux lists sessions by name, so renaming one would move where +later untargeted calls went — and not the most recently active, which moves +whenever any pane produces output. {tooliconl}`show-option`, +{tooliconl}`show-hooks` and {tooliconl}`show-hook` report `resolved_target` so +an untargeted read says which object answered. + +`create_session`, `create_window` and `split_window` return the new pane's id, +so an agent that made the pane never needs to look one up. + +## Names and titles are literals, not tmux formats + +tmux expands `#{...}` in the name argument of `rename-window`, +`rename-session`, `select-pane -T` and `new-session`, and unlike +`set-option` there is no `-F` flag to turn it off. Passed through +unchanged, `rename_window(new_name="#{pane_current_path}")` would name the +window after a directory, and `#{host}` or `#{pane_pid}` would interpolate +server state into a name that shows up in the terminal and in +{tooliconl}`list-panes`. + +These tools escape on the way in, so the name you pass is the name you get. +{tooliconl}`display-message` is the one tool that expands formats, by +design — compose the two when you want an expanded name: + +```console +$ display_message(format_string="#{pane_current_path}") # -> /home/you/src +$ rename_window(new_name="/home/you/src") +``` + +For a window that tracks its own state, prefer tmux's own +`automatic-rename-format` option over re-naming on a timer. + +Option **names** are refused rather than escaped. `set-option` expands the +name too, so `@a#{pane_id}` addresses `@a%0` — but an escaped name cannot be +read back, because libtmux looks the result up under the name you asked for +while tmux answers under the name it stored. A name containing `#` would be +writable and permanently unreadable, so {tooliconl}`set-option` and +{tooliconl}`show-option` reject it. Option *values* were never affected. + +## Signalling a wait channel twice clears its latch + +{tooliconl}`signal-channel` is not idempotent. tmux latches a signal +that has no waiter — that latch is the whole reason to prefer it over +polling — but a second signal on an already-latched channel with no +waiter *removes the channel*, latch included, and the next +{tooliconl}`wait-for-channel` blocks until its ceiling. + +It toggles: one signal latches, two clears, three latches again. The +failure then looks like "the build never finished", because the wait +simply sits there. + +The defensive habit is what triggers it — `tmux wait-for -S done` at the +end of a command *and* again in a trap or cleanup. Signal in exactly one +place. + ## Shell-history suppression is best effort MCP calls to {tooliconl}`run-command` request lightweight suppression by diff --git a/docs/topics/safety.md b/docs/topics/safety.md index 32084c28..513c3c6f 100644 --- a/docs/topics/safety.md +++ b/docs/topics/safety.md @@ -61,17 +61,27 @@ Destructive tools include safeguards against self-harm: - {tool}`kill-window` refuses to kill the window containing the MCP pane - {tool}`kill-pane` refuses to kill the pane running the MCP server +**Nested tmux.** `TMUX` names only the *innermost* server. Run an agent +inside tmux and point it at a second tmux, and the pane hosting its +terminal belongs to the outer server while `TMUX` describes the inner +one — so a socket comparison alone says "different server" and would +permit a kill that takes the caller's own terminal with it. The guard +therefore also asks the caller's own server which terminals are attached +to it: a client of that server occupies a pane of whatever hosts it, so +the inner server's `client_tty` is the outer server's `pane_tty`. That +holds however the nesting arose, including a server that was already +running and merely attached to. + These protections read both the `TMUX` and `TMUX_PANE` environment variables that tmux injects into pane child processes. The `TMUX` value is formatted `socket_path,server_pid,session_id` — libtmux-mcp parses the socket path and compares it to the target server's so the guard only fires when the caller is actually on the same tmux server. A kill across unrelated sockets is allowed; a kill of the caller's own pane/window/session/server is refused. If the caller's socket can't be determined (rare — `TMUX_PANE` set without `TMUX`), the guard errs on the side of blocking. ### macOS `TMUX_TMPDIR` caveat The self-kill guard resolves the target server's socket path in three -steps ({func}`~libtmux_mcp._utils._effective_socket_path` in -`src/libtmux_mcp/_utils.py`): +steps ({func}`~libtmux_mcp._caller._effective_socket_path`): 1. Use {attr}`libtmux.Server.socket_path` if {external+libtmux:doc}`libtmux ` already has it. 2. Otherwise query the running server via `display-message -p '#{socket_path}'` — authoritative because tmux itself reports the path it is actually using, regardless of the MCP process environment. This closes the launchd-vs-interactive-shell gap on macOS where {envvar}`TMUX_TMPDIR` commonly differs between contexts. -3. Fall back to reconstruction from {envvar}`TMUX_TMPDIR` (or `/tmp`) + euid + socket name. Only reached when the target server is unreachable (not running), in which case no self-kill is possible anyway and {func}`~libtmux_mcp._utils._caller_is_on_server`'s None-socket branch blocks conservatively. +3. Fall back to reconstruction from {envvar}`TMUX_TMPDIR` (or `/tmp`) + euid + socket name. Only reached when the target server is unreachable (not running), in which case no self-kill is possible anyway and {func}`~libtmux_mcp._caller._caller_is_on_server`'s None-socket branch blocks conservatively. The structural fix shipped in 0.1.x; setting {envvar}`TMUX_TMPDIR` explicitly is no longer required for the guard to work, though it remains a useful diagnostic when investigating mismatched-path bug reports. @@ -82,6 +92,24 @@ resizes, {toolref}`rename-window` only renames. A few have broader reach because tmux itself exposes broader reach. Treat these as elevated risk even though they share the default tier: +### Running shell commands + +{tool}`run-command` and {tool}`send-keys` execute text in a pane, and a pane's shell can run `tmux`. Measured at `LIBTMUX_SAFETY=mutating`, where {toolref}`kill-window` is not in the tool list at all: + +```json +{"tool": "run_command", "arguments": { + "command": "tmux -L kill-window -t @1", "pane_id": "%1"}} +``` + +Returns `exit_status: 0`, and the window is gone. + +The tier gates which **tools** are exposed, not what a shell can do once you type into it. That is not a hole to be closed: a verb-level guard on command text is bypassed by `t=tmux; $t kill-window`, and refusing it would break the tool's actual purpose. + +What it means in practice: + +- `LIBTMUX_SAFETY=mutating` protects you from an agent that reaches for {toolref}`kill-pane`, not from one that reaches for a shell. If the distinction matters for your threat model, `readonly` is the only tier that holds it — it exposes neither the destructive tools nor the ones that type. +- The audit log records the command text (digested), so a destructive verb sent this way is still visible to a reviewer. + ### Piping pane output {tool}`pipe-pane` pipes a pane's output to a shell command that the server runs. In practice this means the caller chooses an arbitrary path or pipeline on the server host. There is no allow-list. Assume it can create files anywhere the server process can write. @@ -98,7 +126,7 @@ Mitigations: Mitigations: -- The server audit record replaces the `value` argument with a `{len, sha256_prefix}` digest, so the value does not appear verbatim in `libtmux_mcp.audit`. That redaction does not cover separate library, process, application, or client logs, so operators should still treat the tool as high-privilege. +- The server audit record replaces the `value` argument with a `{len, digest}` digest, so the value does not appear verbatim in `libtmux_mcp.audit`. The digest is keyed with a random per-process secret: identical payloads correlate across lines within one server run, and someone reading the log cannot test a guess against it. (An unkeyed hash would not be enough — a recorded length fixes the search space, and a four-digit PIN was recovered from such an entry in 25 ms.) That redaction does not cover separate library, process, application, or client logs, so operators should still treat the tool as high-privilege. - If only a single command needs a non-sensitive env override, prefer having the agent invoke `env VAR=value command` via {tooliconl}`send-keys` instead — the blast radius is one command, not every future child. For credentials, pass a reference that the child resolves instead of a literal value through tmux. ### Respawning panes @@ -109,9 +137,9 @@ Unlike other `mutating` tools, the registration carries `destructiveHint=True` a Mitigations: -- `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get an {exc}`~libtmux_mcp._utils.ExpectedToolError` instead of an unintended kill — resolve via {tool}`list-panes` first. +- `pane_id` is required (no fallback to "first pane in session/window"). Agents that pass only `session_name` get an {exc}`~libtmux_mcp._errors.ExpectedToolError` instead of an unintended kill — resolve via {tool}`list-panes` first. - Any `shell` argument is briefly visible in the OS process table and tmux's `pane_current_command` metadata before the spawned shell takes over; the audit log redacts `shell` payloads (see below), but do not pass credentials directly even with redaction. -- The optional `environment` argument accepts either a mapping of string keys and values or a JSON object string, then maps each item to one tmux `-e KEY=VALUE` flag. For a mapping, the audit log keeps each *key* visible and replaces each *value* with a `{len, sha256_prefix}` digest. A JSON string is redacted as one scalar digest, so its keys are not retained in the audit record. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env. +- The optional `environment` argument accepts either a mapping of string keys and values or a JSON object string, then maps each item to one tmux `-e KEY=VALUE` flag. For a mapping, the audit log keeps each *key* visible and replaces each *value* with a `{len, digest}` digest. A JSON string is redacted as one scalar digest, so its keys are not retained in the audit record. The same OS-process-table caveat as `shell` applies: `respawn-pane -e DB_PASSWORD=...` may briefly appear in `ps` output before the spawned process inherits the env. - The same self-pane guard that protects the destructive kill commands also refuses to respawn the pane running the MCP server. ### Raw pane input @@ -139,7 +167,7 @@ Every tool call emits one `INFO` record on the `libtmux_mcp.audit` logger carryi - `outcome` — `ok` or `error`, with `error_type` on failure - `duration_ms` - `client_id` / `request_id` — from the fastmcp context when available -- `args` — a summary of arguments. Sensitive scalar keys (`keys`, `text`, `command`, `value`, `content`, `shell`, and string-form `environment`) are replaced by `{len, sha256_prefix}`. Mapping-form `environment` keeps its keys but digests each value individually. Non-sensitive strings over 200 characters are truncated. +- `args` — a summary of arguments. Sensitive scalar keys (`keys`, `text`, `command`, `value`, `content`, `shell`, and string-form `environment`) are replaced by `{len, digest}`. Mapping-form `environment` keeps its keys but digests each value individually. Non-sensitive strings over 200 characters are truncated. Route this logger to a dedicated sink if you want a durable audit trail; it is deliberately namespaced separately from the main `libtmux_mcp` logger. diff --git a/docs/topics/troubleshooting.md b/docs/topics/troubleshooting.md index 21f47af7..15f24022 100644 --- a/docs/topics/troubleshooting.md +++ b/docs/topics/troubleshooting.md @@ -115,6 +115,61 @@ what you expect. **Fix**: Check the configured tier. Default is `mutating`, which includes most tools. Only `destructive` enables kill commands. See {ref}`safety`. +## A wedged tmux server, and a test run that hangs with nothing red + +A tmux server can end up *wedged*: the socket accepts connections and +the server never answers. It is not the same as a dead one, and the +difference is what makes it awkward — a dead socket refuses instantly, +which every tool reports correctly, while a wedged one answers nothing +at all. + +Every MCP tool is bounded against this at **every** round trip, not +just the first, and refuses in five seconds naming the tmux subcommand +that stalled: + +``` +tmux list-panes did not return within 5.00s; the tmux server is unresponsive +``` + +The distinction is the whole difficulty. A tool's liveness probe bounds +its first round trip only, and most tools make several — `break_pane` +makes eleven. A socket that answers the probe and then stalls walks +past the guard, so testing this needs a relay that forwards the first +connection to a real server and stalls the rest. One that never answers +is caught by the probe and reports a clean, confident, useless pass. + +What is **not** bounded is a FastMCP `Client` context exiting while +pointed at such a socket: measured, `Client.__aexit__` hangs against a +wedged server and returns cleanly against a healthy or a killed one. + +That matters for test suites rather than for the server. A run that +hangs with **no failing test and no output** is the signature — there +is nothing to grep for, because nothing failed. + +If you hit it, the cheap first question is whether this machine is +hosting a wedged tmux server. A wedged one burns CPU proportional to +its age; an idle one uses almost none, however old it is: + +```console +$ ps -eo pid=,etimes=,cputimes=,comm=,args= \ + | awk '$4=="tmux:" && $5=="server" && $3>10 && $3>$2*0.5 {print $1, $2"s age", $3"s cpu"}' +``` + +Anything it prints is a candidate; silence means no wedged server here. + +Match on `comm` fields, not with `ps -C`. tmux renames the server +process to `tmux: server`, and `ps -C` selects on the command name — so +`ps -C tmux` finds **no tmux servers at all**. Measured on a box +hosting 1,248 of them it returned exactly one row, and that row was an +unrelated shell script that happened to be named `tmux`: a false +negative and a false positive in one command. `ps -C 'tmux: server'` +does not work either, because `-C` cannot match a name containing a +space. + +This project's own tests are on the safe side by construction: they +*kill* servers rather than wedging them, and the two that do build a +silent socket never open a `Client` against it. + ## How to see logs The MCP server uses Python's standard {mod}`logging` module. To see debug diff --git a/docs/topics/waiting.md b/docs/topics/waiting.md index 9a5f8317..2fc139bd 100644 --- a/docs/topics/waiting.md +++ b/docs/topics/waiting.md @@ -73,6 +73,21 @@ tmux has **no hook that fires on pane output** — the `notify_*` set in "wait for text" must poll, tap the pty, or attach a control-mode client. {tooliconl}`wait-for-text` polls. +Polling has a price and it is worth knowing before you reach for a small +`interval`. Each tick spawns two tmux clients — one for pane state, one +for the capture — so a 10 s wait costs roughly: + +| `interval` | CPU for a 10 s wait | +| --- | --- | +| 0.05 (default) | 14% of one core | +| 0.25 | 3.3% of one core | + +`interval` is the sleep *between* ticks, not the period of them: the two +reads happen first and the sleep follows. Below about 0.02 the reads +dominate, so halving the interval stops buying proportionally more polls +while still costing the load. If you are waiting on something that takes +seconds, raise it. + At entry it records an absolute grid anchor (`history_size + cursor_y`) and snapshots the content of the entry cursor row and everything below it. Each tick it re-captures from the anchor and drops rows whose content diff --git a/justfile b/justfile index a0cec91d..3ce136ee 100644 --- a/justfile +++ b/justfile @@ -85,6 +85,56 @@ ruff-format: ruff: uv run ruff check . +# Run the lint gates exactly as CI runs them. +# +# `ruff-format` rewrites files and `mypy` takes a find-derived file list, so +# neither can reproduce a CI lint failure locally: the first fixes what CI +# rejects, the second checks a different set than `mypy .` does. +[group: 'lint'] +lint-ci: + uv run ruff check . + uv run ruff format . --check + uv run mypy . + +# Assert the package imports with dev dependencies absent, as CI does. +# +# A runtime module that imports pytest, ruff or mypy is invisible to every +# other gate -- they all run where those are present - and fails CI at its +# earliest step. +# +# The throwaway UV_PROJECT_ENVIRONMENT is what makes this real. CI runs its +# check BEFORE installing dependencies, so `--no-dev` there has nothing to +# fall back to; locally it reuses .venv, which already has the dev packages, +# and the check passes without ever testing anything. The recipe asserts the +# environment is actually dev-free rather than trusting the flag. +[group: 'lint'] +deps-ci: + #!/usr/bin/env bash + set -euo pipefail + scratch=$(mktemp -d) + trap 'rm -rf "$scratch"' EXIT + env -u VIRTUAL_ENV UV_PROJECT_ENVIRONMENT="$scratch/venv" \ + uv run --no-dev -- python -c ' + import importlib + for dev in ("pytest", "ruff", "mypy"): + try: + importlib.import_module(dev) + except ImportError: + continue + raise SystemExit(f"{dev} is importable; this check is not dev-free") + from libtmux_mcp import main + from libtmux_mcp.__about__ import __version__ + print("libtmux-mcp version:", __version__, "(dev-free)")' + +# Run the suite the way CI runs it: under xdist, with coverage. +# +# `just test` is serial, so a test that depends on ordering or shared state +# passes it and fails CI. Coverage is included because COV_CORE_* changes +# process startup, which is itself a difference worth reproducing. +[group: 'test'] +test-ci: + uv run py.test --cov=./ --cov-append --cov-report=xml -n auto --verbose + # Watch files and run ruff on change [group: 'lint'] watch-ruff: diff --git a/pyproject.toml b/pyproject.toml index 036f261a..735bde0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -272,7 +272,14 @@ addopts = [ "--showlocals", "--doctest-modules", "--doctest-docutils-modules", - "--reruns=2" + "--reruns=2", + # Name every rerun. Without R, an absorbed failure is invisible: the + # run reports "passed" and a test that failed once says nothing. + # "Green" then means "did not fail three times consecutively", which + # is exactly the camouflage a load-sensitivity hunt cannot afford -- + # six single failures were absorbed in silence across one series. + # f and E are pytest's own defaults, kept because -r replaces them. + "-rfER", ] doctest_optionflags = [ "ELLIPSIS", diff --git a/scripts/README.md b/scripts/README.md index d8fc7508..21f07969 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -46,6 +46,43 @@ This matches Claude's conventional dev form and takes advantage of `uv run`'s automatic editable install — source edits flow through on the next invocation with no reinstall step. +### `--pr N` — point every CLI at a pull request + +Review a branch across your agents without checking it out: + +```console +$ uv run scripts/mcp_swap.py use-local --pr 114 +``` + +Each CLI's entry becomes: + +``` +command = "uvx" +args = ["--from", "git+@refs/pull/114/head", "libtmux-mcp"] +``` + +`uv` resolves the ref itself, so nothing lands on disk to refresh or +prune and `revert` restores the config with no extra cleanup. GitHub +publishes `refs/pull/N/head` on the base repository, so a pull request +from a fork needs no special handling. + +Before writing anything, the swap launches the resolved command once and +completes an MCP `initialize` round trip. A ref that does not exist, or a +dependency that cannot resolve, fails there — rather than landing in +every CLI's config and surfacing later as an opaque startup error inside +each agent. Pass `--no-preflight` to skip the probe when offline. + +`gh` confirms the number exists and labels the output; resolution does +not depend on it, so an unauthenticated `gh` degrades to an unlabelled +swap rather than a failure. + +A branch whose dependencies need resolver flags can carry them as +environment, the same way any other setting travels: + +```console +$ uv run scripts/mcp_swap.py use-local --pr 114 --env UV_NO_CONFIG=1 +``` + ### `--scope {user,project}` (Claude only) Claude's `~/.claude.json` supports two config scopes for MCP servers: @@ -155,8 +192,8 @@ swap and leaves the config rewritten. A dialect no existing CLI speaks needs a branch in `McpServerSpec.to_entry_dict` and its mirror in `_spec_from_entry`; that -mirror is what keeps `is_local_uv_directory` and `local_repo_path` -working, and those drive the "already local" short-circuit. +mirror is what keeps `is_local_uv_directory`, `local_repo_path` and +`pr_ref` working, and those drive the "already local" short-circuit. Tests in `tests/test_mcp_swap.py` use a `fake_home` fixture that monkeypatches `CLIS` wholesale, so every new CLI must be added there as diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index ce6f063b..730e2c03 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -8,7 +8,8 @@ Use when you want every installed agent CLI to run a local checkout of an MCP server (editable) instead of a pinned release. ``use-local`` rewrites each CLI's config to invoke the checkout via ``uv --directory run -``; ``revert`` restores from the timestamped backup the swap wrote. +``, or a pull request's head via ``uvx`` with ``--pr``; ``revert`` +restores from the timestamped backup the swap wrote. Swapping a layer that is already swapped keeps that first backup rather than taking a new one, so ``revert`` always lands on the pre-swap config. @@ -25,6 +26,7 @@ $ uv run scripts/mcp_swap.py status $ uv run scripts/mcp_swap.py use-local --dry-run $ uv run scripts/mcp_swap.py use-local +$ uv run scripts/mcp_swap.py use-local --pr 115 $ uv run scripts/mcp_swap.py revert ``` @@ -90,12 +92,17 @@ from __future__ import annotations import argparse +import contextlib import dataclasses import difflib +import fcntl import json import os import pathlib +import re import shutil +import stat +import subprocess import sys import tempfile import time @@ -122,12 +129,10 @@ #: than hardcoded so adding a longer name cannot silently misalign it. _CLI_COLUMN = max(len(name) for name in ALL_CLIS) + 1 -#: Claude config scope: ``"user"`` targets the user/system-level top-level -#: ``mcpServers`` fallback that applies to every project without its own -#: override; ``"project"`` targets the project-level per-project -#: ``projects..mcpServers`` node. Non-Claude CLIs have no -#: per-project scope in their config files, so for those CLIs the scope -#: is always normalised to ``"user"`` regardless of what was passed. +#: Claude config scope. ``"user"`` targets the top-level ``mcpServers`` +#: fallback applying to every project without its own override; +#: ``"project"`` targets ``projects..mcpServers``. Other CLIs have no +#: per-project scope, so theirs normalises to ``"user"``. Scope = t.Literal["user", "project"] ALL_SCOPES: tuple[Scope, ...] = ("user", "project") @@ -219,15 +224,13 @@ def _xdg_state_home() -> pathlib.Path: # --------------------------------------------------------------------------- -#: Per-entry shape a CLI expects under its server map. ``standard`` is -#: the Claude-Desktop lineage every CLI here started from — scalar -#: ``command``, sibling ``args`` list, optional ``env`` table. -#: ``claude`` is that shape plus an explicit ``type``/``env`` that -#: Claude writes even when empty. ``opencode`` packs argv into a single -#: ``command`` array and spells the environment table ``environment``. -#: Dialects exist because the shape is not implied by the file format: -#: two CLIs sharing ``fmt="json"`` can still disagree about how one -#: entry is spelled. +#: Per-entry shape a CLI expects under its server map. ``standard`` is the +#: Claude-Desktop lineage: scalar ``command``, sibling ``args``, optional +#: ``env``. ``claude`` adds an explicit ``type``/``env`` written even when +#: empty. ``opencode`` packs argv into a ``command`` array and spells the +#: environment table ``environment``. A dialect is needed because the +#: shape is not implied by the format -- two CLIs sharing ``fmt="json"`` +#: still disagree about how an entry is spelled. Dialect = t.Literal["standard", "claude", "opencode"] @@ -342,19 +345,23 @@ def _xdg_config_home() -> pathlib.Path: #: keeps the swap from being followed by a surprise rewrite. OPENCODE_SCHEMA_URL = "https://opencode.ai/config.json" -#: pi ships no MCP client — its README says "No MCP" outright, and the -#: released build contains no MCP code at all. MCP reaches pi only -#: through the third-party ``pi-mcp-adapter`` extension, which is what -#: reads ``~/.pi/agent/mcp.json``. The swap writes that file because it -#: is the one pi-family location with a settled schema, but until the -#: adapter is installed pi does not read it, so ``detect`` says so -#: instead of reporting a swap that cannot take effect. +#: pi ships no MCP client; MCP reaches it only through the third-party +#: ``pi-mcp-adapter`` extension, which reads ``~/.pi/agent/mcp.json``. The +#: swap writes that file as the one pi-family location with a settled +#: schema, but until the adapter is installed pi does not read it -- so +#: ``detect`` says so rather than report a swap that cannot take effect. PI_ADAPTER_DIR = ( pathlib.Path.home() / ".pi" / "agent" / "npm" / "node_modules" / "pi-mcp-adapter" ) PI_ADAPTER_HINT = "needs the pi-mcp-adapter package; pi has no built-in MCP client" +#: A ``--from`` argument pointing at a pull request's head commit. +#: GitHub publishes ``refs/pull//head`` on the *base* repository, so +#: one URL serves same-repo and fork pull requests alike. +PR_REF_RE = re.compile(r"git\+(?P.+?)@refs/pull/(?P\d+)/head") + + @dataclasses.dataclass class McpServerSpec: """The portable shape shared across CLI configs.""" @@ -406,6 +413,16 @@ def local_repo_path(self) -> pathlib.Path | None: return None return pathlib.Path(self.args[i + 1]) + def pr_ref(self) -> tuple[str, int] | None: + """Return ``(repo_url, pr_number)`` for a ``uvx`` pull-request spec.""" + if self.command != "uvx": + return None + for arg in self.args: + match = PR_REF_RE.fullmatch(arg) + if match: + return match.group("url"), int(match.group("number")) + return None + @dataclasses.dataclass class SwapEntry: @@ -420,14 +437,21 @@ class SwapEntry: #: separately via :attr:`seq_no` so this field stays purely #: descriptive. swapped_at: str - #: Monotonic registration counter — the primary LIFO sort key for - #: ``cmd_revert``. ``cmd_use_local`` computes the next value as - #: ``max(existing seq_nos, default=-1) + 1`` so it strictly - #: increases per swap regardless of wall-clock collisions or dict - #: iteration order. Same explicit-counter pattern CPython's - #: ``Lib/sched.py`` uses to break ties on ``Event(time, priority, - #: sequence, …)``. + #: Monotonic registration counter, the primary LIFO sort key for + #: ``cmd_revert``. ``cmd_use_local`` takes ``max(existing seq_nos, + #: default=-1) + 1``, so it increases strictly per swap whatever the + #: wall clock or dict iteration order does. The explicit-counter + #: pattern is CPython's, from ``Lib/sched.py``. seq_no: int + #: Exact destination changed by the swap. ``config_path`` may be a + #: symlink that is later repointed, so it is not sufficient recovery + #: identity. Older state entries omit this field and fall back to + #: ``config_path`` during revert. + target_path: str | None = None + + +class SwapStateError(RuntimeError): + """Swap state is unsafe to use for a mutating operation.""" # --------------------------------------------------------------------------- @@ -435,16 +459,14 @@ class SwapEntry: # --------------------------------------------------------------------------- # # tomlkit gives TOML a format-preserving round trip; JSONC has no -# equivalent on PyPI that is safe to depend on here. ``json-five`` was -# measured first and rejected: it raises on ``"C:\\x"`` and silently -# decodes the literal six characters ``\u0041`` to ``"A"`` — both valid -# JSON that stdlib reads correctly, and the second is exactly the silent -# rewrite this script exists to avoid. +# equivalent on PyPI safe to depend on here. ``json-five`` raises on +# ``"C:\\x"`` and silently decodes the literal ``\u0041`` to ``"A"`` -- +# both valid JSON that stdlib reads correctly, and the second is the +# silent rewrite this script exists to avoid. # -# So values come from stdlib ``json`` (correct escape semantics) and -# edits are applied as text splices located by an offset-preserving -# scanner. Every byte outside a replaced value survives untouched, which -# is the same technique opencode's own config writer uses via +# So values come from stdlib ``json`` and edits are text splices located +# by an offset-preserving scanner, leaving every byte outside a replaced +# value untouched -- the technique opencode's own writer uses through # ``jsonc-parser``'s ``modify()``. _JSON_WS = " \t\n\r" @@ -789,28 +811,79 @@ def load_config(info: CLIInfo) -> t.Any: return tomlkit.parse(raw.decode()) +def _json_trailer(original: bytes) -> str: + """Return the newline a rewritten JSON config should end with. + + Claude writes ``~/.claude.json`` without a trailing newline, so + appending one unconditionally grows the file by a byte on every swap + and shows as a diff hunk in a region the swap never touched. Empty + bytes mean a file being seeded, which gets the conventional newline. + """ + if not original: + return "\n" + return "\n" if original.endswith(b"\n") else "" + + def dump_config_bytes(info: CLIInfo, config: t.Any, *, original: bytes) -> bytes: - """Serialize an edited config back to bytes in its original format.""" + """Serialize an edited config back to bytes in its original format. + + ``original`` is the file's pre-edit bytes, or empty when seeding a + new one. The parsed structure does not record the byte-level + conventions of the file it came from, so they are carried over from + the source instead. Required rather than defaulted: a caller that + omitted it would silently start rewriting regions it never touched, + which is the defect this parameter exists to prevent. tomlkit + preserves those conventions itself; only the JSON writer needs it. + """ + # Dispatched on the exact format rather than "not json": a third + # format reaching the TOML writer by fall-through would silently + # write TOML bytes into a JSON file. + if info.fmt == "toml": + return tomlkit.dumps(config).encode() if info.fmt == "jsonc": + # The merge derives its output from the original text, so the + # file's own trailing-newline convention carries over untouched + # and needs no _json_trailer fixup. source = original.decode() try: return _jsonc_merge(source, config, ensure_ascii=False).encode() except UnicodeEncodeError: return _jsonc_merge(source, config, ensure_ascii=True).encode() - if info.fmt == "json": - return (json.dumps(config, indent=2) + "\n").encode() - return tomlkit.dumps(config).encode() + trailer = _json_trailer(original) + # ensure_ascii would re-escape every non-ASCII character in the file, + # including config text the swap never read. + text = json.dumps(config, indent=2, ensure_ascii=False) + trailer + try: + return text.encode() + except UnicodeEncodeError: + # A lone surrogate — a JS writer slicing a string mid-pair — has no + # UTF-8 encoding. Escaping the document is then the only form that + # can be written at all. + return (json.dumps(config, indent=2) + trailer).encode() def atomic_write(path: pathlib.Path, data: bytes) -> None: - """Write bytes to ``path`` via tempfile + ``os.replace`` to avoid partial writes.""" - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + """Write bytes to ``path`` without replacing a symlinked config. + + Parameters + ---------- + path : pathlib.Path + Destination path. A symlink resolves to its final target so the + write preserves every link in the chain. + data : bytes + Bytes to write atomically. + """ + target = path.resolve() if path.is_symlink() else path + target.parent.mkdir(parents=True, exist_ok=True) + mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else None + fd, tmp_name = tempfile.mkstemp(prefix=target.name + ".", dir=str(target.parent)) tmp = pathlib.Path(tmp_name) try: with os.fdopen(fd, "wb") as fh: + if mode is not None: + os.fchmod(fh.fileno(), mode) fh.write(data) - tmp.replace(path) + tmp.replace(target) except Exception: tmp.unlink(missing_ok=True) raise @@ -1135,8 +1208,8 @@ def _spec_from_entry(entry: t.Any, *, info: CLIInfo) -> McpServerSpec: Every dialect is normalised down to the portable scalar-command shape, so the helpers that reason about a spec — - :meth:`McpServerSpec.is_local_uv_directory` and ``_points_at`` — stay - dialect-agnostic. Skipping this is not a + :meth:`McpServerSpec.is_local_uv_directory`, :meth:`McpServerSpec.pr_ref`, + ``_points_at`` — stay dialect-agnostic. Skipping this is not a cosmetic loss: an unsplit array command makes the "already local, no change" check miss, and every run rewrites a config it did not need to touch. @@ -1206,12 +1279,167 @@ def build_local_spec(repo: pathlib.Path, entry: str) -> McpServerSpec: ) +def build_pr_spec(repo_url: str, pr: int, entry: str) -> McpServerSpec: + """Build the ``uvx --from git+@refs/pull//head `` spec. + + Nothing is checked out: ``uv`` resolves the ref itself, so a swap + leaves no worktree to refresh or prune and ``revert`` needs no + cleanup beyond restoring the config. + """ + return McpServerSpec( + command="uvx", + args=["--from", f"git+{repo_url}@refs/pull/{pr}/head", entry], + ) + + +def _run_text(argv: list[str], cwd: pathlib.Path | None = None) -> str: + """Run ``argv`` and return stdout, raising on a non-zero exit.""" + return subprocess.run( + argv, + cwd=None if cwd is None else str(cwd), + capture_output=True, + text=True, + check=True, + ).stdout + + +def remote_https_url(repo: pathlib.Path, remote: str = "origin") -> str: + """Return ``https:////`` for a repo's git remote. + + Normalizes the spellings git accepts — ``git@host:owner/name.git``, + an ``ssh://`` or ``git+ssh://`` scheme, an embedded user, a trailing + ``.git`` — because the pull-request ref is fetched over https however + the working copy was cloned. + """ + try: + raw = _run_text(["git", "-C", str(repo), "remote", "get-url", remote]) + except (OSError, subprocess.CalledProcessError) as exc: + msg = f"cannot read git remote {remote!r} in {repo}" + raise RuntimeError(msg) from exc + return _normalize_remote_url(raw.strip()) + + +def _normalize_remote_url(url: str) -> str: + """Rewrite any git remote spelling as a plain https URL. + + Examples + -------- + >>> _normalize_remote_url("git+ssh://git@github.com/o/n.git") + 'https://github.com/o/n' + >>> _normalize_remote_url("git@github.com:o/n.git") + 'https://github.com/o/n' + >>> _normalize_remote_url("https://github.com/o/n") + 'https://github.com/o/n' + """ + url = url.removeprefix("git+") + if url.startswith("ssh://"): + url = "https://" + url.removeprefix("ssh://") + elif "://" not in url and ":" in url: + host, _, path = url.partition(":") + url = f"https://{host}/{path}" + scheme, sep, rest = url.partition("://") + authority, slash, path = rest.partition("/") + return f"{scheme}{sep}{authority.rpartition('@')[2]}{slash}{path}".removesuffix( + ".git" + ) + + +def gh_pr_summary(repo: pathlib.Path, pr: int) -> dict[str, t.Any] | None: + """Return ``gh``'s view of a pull request, or ``None`` when unreadable. + + Used to confirm the number exists and to label output. Resolution + does not depend on it: the ref and URL come from git, so a missing + or unauthenticated ``gh`` degrades to an unlabelled swap rather than + a failure. + """ + try: + out = _run_text( + [ + "gh", + "pr", + "view", + str(pr), + "--json", + "number,title,state,headRefName,isCrossRepository", + ], + cwd=repo, + ) + except (OSError, subprocess.CalledProcessError): + return None + try: + loaded = json.loads(out) + except json.JSONDecodeError: + return None + return loaded if isinstance(loaded, dict) else None + + +#: One MCP ``initialize`` request, newline-framed for stdio. +_INITIALIZE_FRAME = ( + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "mcp_swap-preflight", "version": "1"}, + }, + } + ) + + "\n" +) + + +def preflight_spec(spec: McpServerSpec, *, timeout: float = 300.0) -> str | None: + """Launch ``spec`` and complete one MCP ``initialize`` round trip. + + Returns ``None`` when the server answered, otherwise a reason to + show the operator. A pull-request spec resolves its dependencies at + launch time, inside whichever agent starts it, so an unresolvable + ref would otherwise land in every config and surface later as an + opaque startup failure in each one. + + Closing stdin after the frame lets a well-behaved stdio server exit + on its own, which keeps this free of signal handling. + """ + try: + proc = subprocess.Popen( + [spec.command, *spec.args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, **spec.env}, + text=True, + ) + except OSError as exc: + return f"could not launch {spec.command}: {exc}" + + try: + out, err = proc.communicate(_INITIALIZE_FRAME, timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + return f"no MCP response within {timeout:.0f}s" + + for line in out.splitlines(): + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(message, dict) and message.get("id") == 1 and "result" in message: + return None + + tail = "\n".join(err.strip().splitlines()[-3:]) + return tail or "server exited without answering initialize" + + # --------------------------------------------------------------------------- # State file # --------------------------------------------------------------------------- -def load_state() -> dict[tuple[CLIName, Scope], SwapEntry]: +def load_state(*, strict: bool = False) -> dict[tuple[CLIName, Scope], SwapEntry]: """Read the swap-state file, returning an empty mapping when absent. The state file's schema is internal — no compatibility contract — @@ -1219,23 +1447,68 @@ def load_state() -> dict[tuple[CLIName, Scope], SwapEntry]: (those that don't parse as ``cli:scope``) and entries with a non-coercible ``seq_no`` or missing required fields are dropped silently so a hand-edited file cannot crash the script. + + A file that will not parse at all is reported rather than dropped + silently: it means the record of every swap is gone, so ``revert`` + is about to say there is nothing to unwind while swapped configs + and their backups sit on disk. Saying so is what lets the operator + go find those backups. Mutating callers pass ``strict=True`` so an + unreadable or malformed record blocks changes instead of being + overwritten as empty state. """ if not STATE_FILE.exists(): return {} - raw = json.loads(STATE_FILE.read_text()) + try: + raw = json.loads(STATE_FILE.read_text()) + except (OSError, ValueError) as exc: + message = f"swap state unreadable ({STATE_FILE}): {exc}" + print(message, file=sys.stderr) + if strict: + raise SwapStateError(message) from exc + return {} + if not isinstance(raw, dict): + if strict: + message = f"swap state has invalid shape: {STATE_FILE}" + print(message, file=sys.stderr) + raise SwapStateError(message) + return {} entries = raw.get("entries", {}) + if not isinstance(entries, dict): + if strict: + message = f"swap state has invalid entries: {STATE_FILE}" + print(message, file=sys.stderr) + raise SwapStateError(message) + entries = {} out: dict[tuple[CLIName, Scope], SwapEntry] = {} for k, v in entries.items(): parsed = _parse_state_key(k) if parsed is None: + if strict: + message = f"swap state has invalid key {k!r}: {STATE_FILE}" + print(message, file=sys.stderr) + raise SwapStateError(message) continue entry = _parse_state_entry(v) if entry is None: + if strict: + message = f"swap state has invalid entry {k!r}: {STATE_FILE}" + print(message, file=sys.stderr) + raise SwapStateError(message) continue out[parsed] = entry return out +@contextlib.contextmanager +def _state_lock() -> t.Iterator[None]: + """Serialize config mutations that share the swap state file.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + fd = os.open(STATE_DIR / "state.lock", os.O_RDWR | os.O_CREAT, 0o600) + with os.fdopen(fd, "a+b") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + yield + + def save_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: """Write the swap-state file atomically.""" STATE_DIR.mkdir(parents=True, exist_ok=True) @@ -1248,13 +1521,10 @@ def save_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) -def clear_state(keys: t.Iterable[tuple[CLIName, Scope]]) -> None: - """Remove the given ``(cli, scope)`` keys; delete the file if empty.""" - current = load_state() - for key in keys: - current.pop(key, None) - if current: - save_state(current) +def _save_or_clear_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: + """Persist ``entries``, removing the state file when the mapping is empty.""" + if entries: + save_state(entries) elif STATE_FILE.exists(): STATE_FILE.unlink() @@ -1381,27 +1651,44 @@ def cmd_status(args: argparse.Namespace) -> int: print( f"[{cli}] {server} = {spec.command} {' '.join(spec.args)} ({tag})" ) - except RuntimeError as exc: + except (RuntimeError, ValueError, OSError) as exc: print(f"[{cli}] {exc}", file=sys.stderr) continue return 0 def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: - """Return a short label classifying a spec (local/pypi-pin/other).""" + """Return a short label classifying a spec (local/PR/pypi-pin/other).""" if spec.is_local_uv_directory(): local = spec.local_repo_path() if local and local.resolve() == repo.resolve(): return "local: this repo" return f"local: {local}" + pr = spec.pr_ref() + if pr is not None: + # Checked before the pin branch below: a PR ref contains `@`, + # which that branch would report as a version pin. + return f"PR #{pr[1]}: {pr[0]}" if spec.command == "uvx": pinned = next((a for a in spec.args if "==" in a or "@" in a), None) return f"pypi pin: {pinned}" if pinned else "pypi (unpinned)" return "other" -def cmd_use_local(args: argparse.Namespace) -> int: - """Rewrite each target CLI's config to run the repo's checkout via ``uv``. +def _points_at( + current: McpServerSpec, target: McpServerSpec, repo: pathlib.Path +) -> bool: + """Return True when ``current`` already runs what ``target`` describes.""" + if target.pr_ref() is not None: + return current.pr_ref() == target.pr_ref() + return current.is_local_uv_directory() and current.local_repo_path() == repo + + +def _cmd_use_local(args: argparse.Namespace) -> int: + """Rewrite each target CLI's config to run the repo, or a pull request. + + Without ``--pr`` the entry runs the repo's checkout via ``uv``; with + it, the pull request's head via ``uvx``. The optional ``--scope`` flag selects Claude's user-level fallback vs. per-project override; see :data:`Scope`. The flag is silently @@ -1411,9 +1698,30 @@ def cmd_use_local(args: argparse.Namespace) -> int: server, default_entry = resolve_repo_meta(repo) server = args.server or server entry = args.entry or default_entry - spec = build_local_spec(repo, entry) extra_env = dict(args.env or []) + pr = getattr(args, "pr", None) + if pr is None: + spec = build_local_spec(repo, entry) + else: + try: + spec = build_pr_spec(remote_https_url(repo), pr, entry) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + spec = dataclasses.replace(spec, env=dict(extra_env)) + summary = gh_pr_summary(repo, pr) + if summary is None: + print(f"PR #{pr}: gh could not read it — swapping anyway", file=sys.stderr) + else: + fork = " (fork)" if summary.get("isCrossRepository") else "" + print( + f"PR #{summary.get('number', pr)} [{summary.get('state', '?')}]" + f"{fork} {summary.get('headRefName', '?')} — " + f"{summary.get('title', '')}", + file=sys.stderr, + ) + hint = _naming_hint(repo, server) if hint: print(hint, file=sys.stderr) @@ -1423,8 +1731,17 @@ def cmd_use_local(args: argparse.Namespace) -> int: print("no CLIs detected — nothing to do", file=sys.stderr) return 1 + # Runs under --dry-run too: resolving the ref is the only signal a + # dry run can give about whether the swap would actually start. + if pr is not None and not args.no_preflight: + print(f"preflight: {spec.command} {' '.join(spec.args)}", file=sys.stderr) + failure = preflight_spec(spec) + if failure is not None: + print(f"preflight failed, nothing written:\n{failure}", file=sys.stderr) + return 1 + ts = time.strftime("%Y%m%d%H%M%S") - state = load_state() + state = load_state(strict=True) had_error = 0 for cli in targets: scope = _normalize_scope(cli, args.scope) @@ -1432,23 +1749,25 @@ def cmd_use_local(args: argparse.Namespace) -> int: info = CLIS[cli] if not info.config_path.exists(): print(f"[{label}] skip — config not found at {info.config_path}") + had_error = 1 continue - # Wrap the read + shape-guarded mutation in try/except RuntimeError - # so a malformed Claude config (top-level mcpServers / projects not a - # mapping) surfaces as a clean per-CLI error instead of an uncaught - # traceback. Same per-CLI continuation pattern the inner write-failure - # handler below uses. + target_path = info.config_path.resolve() + target_info = dataclasses.replace(info, config_path=target_path) + # The three arms are the three ways the read fails: a rejected + # shape raises RuntimeError, an unparseable config ValueError (JSON, + # TOML and UTF-8 decode errors all derive from it), an unopenable + # one OSError. Same trio ``doctor`` catches. try: - original_bytes = info.config_path.read_bytes() - config = load_config(info) + original_bytes = target_path.read_bytes() + config = load_config(target_info) current = get_server(cli, config, server, repo, scope=scope) if ( current - and current.is_local_uv_directory() - and current.local_repo_path() == repo + and _points_at(current, spec, repo) and all(current.env.get(k) == v for k, v in extra_env.items()) ): - print(f"[{label}] already local (this repo) — no change") + where = "local (this repo)" if pr is None else f"PR #{pr}" + print(f"[{label}] already {where} — no change") continue # Preserve the existing entry's env on replacement. ``build_local_spec`` # writes an empty env, so without this merge a swap would silently drop @@ -1464,7 +1783,7 @@ def cmd_use_local(args: argparse.Namespace) -> int: ) action = set_server(cli, config, server, cli_spec, repo, scope=scope) new_bytes = dump_config_bytes(info, config, original=original_bytes) - except RuntimeError as exc: + except (RuntimeError, ValueError, OSError) as exc: print(f"[{label}] {exc}", file=sys.stderr) had_error = 1 continue @@ -1481,13 +1800,11 @@ def cmd_use_local(args: argparse.Namespace) -> int: continue # Re-swapping a layer that was never reverted must NOT re-back-up: - # ``original_bytes`` is this script's own earlier output, so - # recording it would make ``revert`` restore a swapped config and - # strand the pristine one. Keep the first backup — it is the only - # copy of what the user had — and leave its ``seq_no`` / - # ``swapped_at`` untouched so the LIFO unwind order (which is - # pinned by what each backup captured, not by when it was last - # rewritten) stays correct. + # ``original_bytes`` would be this script's own earlier output, so + # ``revert`` would restore a swapped config and strand the pristine + # one. The first backup is the only copy of what the user had, and + # its ``seq_no``/``swapped_at`` stay put so the LIFO order keeps + # tracking what each backup captured. prior = state.get((cli, scope)) prior_backup = pathlib.Path(prior.backup_path) if prior is not None else None if prior_backup is not None and prior_backup.exists(): @@ -1509,22 +1826,21 @@ def cmd_use_local(args: argparse.Namespace) -> int: backup_suffix = f"{BACKUP_SUFFIX_PREFIX}{ts}" if cli == "claude": backup_suffix += f"-{scope}" - backup_path = write_new_backup( - info.config_path.with_suffix(info.config_path.suffix + backup_suffix), - original_bytes, - ) + # A backup that cannot be written must abort this CLI rather + # than degrade into a swap with nothing to revert to — an + # unwritable directory is the case that produces both. + try: + backup_path = write_new_backup( + info.config_path.with_suffix( + info.config_path.suffix + backup_suffix + ), + original_bytes, + ) + except OSError as exc: + print(f"[{label}] cannot write backup: {exc}", file=sys.stderr) + had_error = 1 + continue backup_note = f"backup: {backup_path}" - try: - atomic_write(info.config_path, new_bytes) - _revalidate(info) - except Exception as exc: - atomic_write(info.config_path, original_bytes) - print( - f"[{label}] write failed ({exc}); backup at {backup_path}", - file=sys.stderr, - ) - had_error = 1 - continue if prior is not None and backup_path == prior_backup: # ``swapped_at`` mirrors the timestamp in the backup filename # and ``seq_no`` fixes the backup's place in the unwind @@ -1533,27 +1849,76 @@ def cmd_use_local(args: argparse.Namespace) -> int: else: seq_no = max((e.seq_no for e in state.values()), default=-1) + 1 swapped_at = ts - state[(cli, scope)] = SwapEntry( + next_state = dict(state) + next_state[(cli, scope)] = SwapEntry( config_path=str(info.config_path), backup_path=str(backup_path), server=server, action=action, swapped_at=swapped_at, seq_no=seq_no, + target_path=str(target_path), ) + try: + save_state(next_state) + except OSError as exc: + print( + f"[{label}] cannot save recovery state ({exc}); config unchanged; " + f"backup at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + previous_state = state + state = next_state + try: + atomic_write(target_path, new_bytes) + _revalidate(target_info) + except Exception as exc: + try: + atomic_write(target_path, original_bytes) + except Exception as rollback_exc: + rollback_note = f"; rollback failed ({rollback_exc})" + else: + rollback_note = "; original config restored" + try: + _save_or_clear_state(previous_state) + except OSError as state_exc: + rollback_note += f"; recovery state cleanup failed ({state_exc})" + else: + state = previous_state + print( + f"[{label}] write failed ({exc}){rollback_note}; " + f"backup at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue print(f"[{label}] {action}; {backup_note}") - if not args.dry_run: - save_state(state) return had_error +def cmd_use_local(args: argparse.Namespace) -> int: + """Run :func:`_cmd_use_local` under the shared mutation lock.""" + if args.dry_run: + return _cmd_use_local(args) + try: + with _state_lock(): + return _cmd_use_local(args) + except SwapStateError: + return 1 + except OSError as exc: + print(f"swap state unavailable: {exc}", file=sys.stderr) + return 1 + + def _revalidate(info: CLIInfo) -> None: """Re-parse the file after writing; raise on failure.""" load_config(info) -def cmd_revert(args: argparse.Namespace) -> int: +def _cmd_revert(args: argparse.Namespace) -> int: """Restore each target CLI's config from the backup recorded in the state file. Without ``--scope``, every recorded entry for the targeted CLIs is @@ -1562,14 +1927,14 @@ def cmd_revert(args: argparse.Namespace) -> int: the matching scope is reverted; the parameter is silently coerced to ``"user"`` for non-Claude CLIs. """ - state = load_state() + state = load_state(strict=True) # Without --cli, revert every CLI that has any recorded swap. targets = list(args.cli) if args.cli else list({cli for cli, _scope in state}) if not targets: print("no recorded swaps — nothing to revert", file=sys.stderr) return 1 - reverted: list[tuple[CLIName, Scope]] = [] + had_error = 0 for cli in targets: if args.scope is not None: wanted_scopes: tuple[Scope, ...] = (_normalize_scope(cli, args.scope),) @@ -1584,46 +1949,70 @@ def cmd_revert(args: argparse.Namespace) -> int: label = f"{cli}:{args.scope}" if args.scope and cli == "claude" else cli print(f"[{label}] no state entry — skip") continue - # Unwind in reverse-registration order (LIFO) — sort by the - # explicit ``SwapEntry.seq_no`` counter so order is independent - # of JSON parse order, dict iteration, and wall-clock - # collisions. ``seq_no`` is coerced to ``int`` at load time by - # ``_parse_state_entry``; entries with a non-coercible value - # are dropped before they reach this sort, so the comparison - # is always int vs int. When two scopes back the same physical - # file (Claude user + project), the later swap's backup - # contains the earlier swap's modifications, so each backup - # must restore its own layer before the prior one is restored. - # Same explicit counter pattern CPython's ``Lib/sched.py`` uses - # to break ties on ``Event(time, priority, sequence, …)``. + # Unwind LIFO by the explicit ``SwapEntry.seq_no`` -- CPython's + # ``Lib/sched.py`` counter pattern -- so order does not depend on + # JSON parse order, dict iteration or the wall clock. + # ``_parse_state_entry`` coerces it to ``int`` and drops entries + # that cannot be, so this compares int to int. When two scopes back + # the same physical file (Claude user + project), the later swap's + # backup contains the earlier swap's modifications, so each layer + # must restore before the one under it. cli_keys.sort(key=lambda k: state[k].seq_no, reverse=True) for key in cli_keys: sc_cli, sc_scope = key entry = state[key] label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli backup = pathlib.Path(entry.backup_path) - dest = pathlib.Path(entry.config_path) + dest = pathlib.Path(entry.target_path or entry.config_path) if not backup.exists(): print(f"[{label}] backup missing: {backup}", file=sys.stderr) - continue + had_error = 1 + break if args.dry_run: print(f"[{label}] would restore {dest} from {backup}") continue - atomic_write(dest, backup.read_bytes()) - # Backup served its purpose; LIFO unwind for this layer is - # complete. Delete on success, keep on error — same idiom - # CPython's ``tempfile.NamedTemporaryFile`` uses - # (Lib/tempfile.py:614-618). If ``atomic_write`` had raised, - # this line wouldn't run and the backup would survive for - # post-mortem; on success the backup is redundant and would - # otherwise accumulate forever across swap/revert cycles. - backup.unlink() + try: + atomic_write(dest, backup.read_bytes()) + except OSError as exc: + print(f"[{label}] restore failed: {exc}", file=sys.stderr) + had_error = 1 + break + next_state = dict(state) + next_state.pop(key) + try: + _save_or_clear_state(next_state) + except OSError as exc: + print( + f"[{label}] restored, but recovery state could not be updated: " + f"{exc}", + file=sys.stderr, + ) + had_error = 1 + break + state = next_state + try: + backup.unlink() + except OSError as exc: + print( + f"[{label}] restored; backup cleanup failed: {exc}", file=sys.stderr + ) + had_error = 1 print(f"[{label}] restored from {backup}") - reverted.append(key) + return had_error - if not args.dry_run and reverted: - clear_state(reverted) - return 0 + +def cmd_revert(args: argparse.Namespace) -> int: + """Run :func:`_cmd_revert` under the shared mutation lock.""" + if args.dry_run: + return _cmd_revert(args) + try: + with _state_lock(): + return _cmd_revert(args) + except SwapStateError: + return 1 + except OSError as exc: + print(f"swap state unavailable: {exc}", file=sys.stderr) + return 1 # --------------------------------------------------------------------------- @@ -1653,6 +2042,19 @@ def _env_pair(raw: str) -> tuple[str, str]: return key, value +def _pr_number(raw: str) -> int: + """Parse a ``--pr`` argument as a pull-request number, or raise for argparse.""" + try: + number = int(raw) + except ValueError: + msg = f"--pr expects a number, got {raw!r}" + raise argparse.ArgumentTypeError(msg) from None + if number < 1: + msg = f"--pr expects a positive number, got {number}" + raise argparse.ArgumentTypeError(msg) + return number + + def _config_present_clis() -> list[CLIName]: """CLIs whose config file exists — enough to *read* entries (no binary needed). @@ -1859,8 +2261,30 @@ def build_parser() -> argparse.ArgumentParser: ) ps.set_defaults(func=cmd_status) - pu = sub.add_parser("use-local", help="rewrite configs to run this checkout") + pu = sub.add_parser( + "use-local", help="rewrite configs to run this checkout, or a pull request" + ) pu.add_argument("--repo", default=".", help="repo root (default: .)") + pu.add_argument( + "--pr", + type=_pr_number, + metavar="N", + help=( + "Point the CLIs at pull request N instead of the working copy. " + "Writes 'uvx --from git+@refs/pull/N/head ', so " + "nothing is checked out and 'revert' needs no cleanup. The ref " + "lives on the base repo, so fork PRs work unchanged." + ), + ) + pu.add_argument( + "--no-preflight", + action="store_true", + help=( + "Skip the MCP initialize round trip --pr runs before writing. " + "The probe resolves the ref once so a bad PR fails here instead " + "of inside every agent; skip it when offline or already warm." + ), + ) pu.add_argument( "--server", help="MCP server name (default: derived from pyproject.toml)" ) diff --git a/src/libtmux_mcp/__init__.py b/src/libtmux_mcp/__init__.py index 64fcaf8f..0a060187 100644 --- a/src/libtmux_mcp/__init__.py +++ b/src/libtmux_mcp/__init__.py @@ -32,17 +32,15 @@ def main(argv: t.Sequence[str] | None = None) -> None: try: from libtmux_mcp.server import run_server except ImportError as exc: - # Name the module that actually failed. This catch spans the - # WHOLE server import tree, and it used to blame fastmcp for - # everything it caught — which is close to the one cause it - # cannot have, since fastmcp is a hard dependency. Measured - # against mcp 2.0.0b2, which deleted ``mcp.types``: the server - # printed "requires fastmcp" while fastmcp was installed and - # importable. + # Name the module that actually failed: this catch spans the WHOLE + # server import tree, so blaming fastmcp names the one cause it + # cannot have -- it is a hard dependency. Under mcp 2.0.0b2, which + # deleted ``mcp.types``, that reads "requires fastmcp" while + # fastmcp is installed and importable. # - # This string is the whole diagnosis. An MCP client sees one - # stderr line before the pipe closes and then reports nothing - # more useful than "Connection closed". + # This string is the whole diagnosis: an MCP client sees one stderr + # line before the pipe closes, then reports only "Connection + # closed". blamed = (exc.name or "").split(".")[0] subject = f"cannot import {blamed!r}: {exc}" if blamed else str(exc) print( diff --git a/src/libtmux_mcp/_bounded_io.py b/src/libtmux_mcp/_bounded_io.py new file mode 100644 index 00000000..c1f34ba5 --- /dev/null +++ b/src/libtmux_mcp/_bounded_io.py @@ -0,0 +1,394 @@ +"""Bounded tmux reads, shared across the tool modules. + +Two different bounds live here. The wall-clock one keeps a single tmux +invocation killable; the SIZE one (:func:`_truncate_lines_tail` and +:data:`CAPTURE_DEFAULT_MAX_LINES`) keeps a large result from blowing the +agent's context window. The size half moved here from +``pane_tools/io.py`` because ``buffer_tools`` needed it too, and a tool +module importing from a sibling tool module made the package +import-order-dependent -- adding ``copy_selection``, which needs the +buffer helpers, closed that into a genuine cycle. + +Split out of ``wait.py`` so ``capture_since`` can use the same reads +without an import cycle -- ``wait.py`` imports ``_limit_lines`` from +there. Every function here goes through +:func:`libtmux_mcp._tmux_proc._run_tmux_bounded`, which owns a killable +subprocess rather than a worker thread: a thread blocked in libtmux's +untimed ``Popen.communicate()`` cannot be cancelled, and +``concurrent.futures.thread._python_exit`` joins pool workers untimed at +shutdown, so one wedged tmux takes process exit with it. + +Building argv here rather than going through libtmux means the flags +are ours to get right, and CI runs tmux 3.2a upward. Check a new one +against the oldest supported version before using it -- the arg string +in tmux's own source is authoritative:: + + git show 3.2a:cmd-capture-pane.c | grep -m1 'args = {' +""" + +from __future__ import annotations + +import time +import typing as t + +from libtmux import exc + +from libtmux_mcp._errors import ExpectedToolError +from libtmux_mcp._exec import _LIVENESS_TIMEOUT_SECONDS, _tmux_argv +from libtmux_mcp._pane_state import ( + HISTORY_LIMIT_FORMAT, + PANE_STATE_FORMAT, + _parse_pane_state, +) +from libtmux_mcp._resolve import tmux_id_sort_key +from libtmux_mcp._tmux_proc import _run_tmux_bounded + +if t.TYPE_CHECKING: # pragma: no cover - typing only + from libtmux.server import Server + + from libtmux_mcp._pane_state import _PaneState + + +#: Per-``tmux``-invocation wall-clock bound, and the load-bearing half of +#: the wait ceiling. libtmux runs tmux through ``Popen.communicate()`` +#: with no timeout, and ``mcp.tool(timeout=...)`` bounds only the +#: coroutine, so neither bounds the work. The wait path spawns tmux itself +#: as a killable async subprocess -- not a thread, since a worker stuck in +#: ``Popen.communicate()`` cannot be cancelled and ``_python_exit`` joins +#: pool workers untimed, so one wedged tmux hangs process exit and Ctrl-C +#: forever -- recognisable by a 300 s pause and a ``RuntimeWarning`` from +#: ``shutdown_default_executor``. +#: +#: A CEILING on a single call; :func:`_call_budget` lowers it to what +#: remains of the caller's deadline, so a wait cannot overshoot by a whole +#: call's worth. +_TMUX_CALL_TIMEOUT_SECONDS = _LIVENESS_TIMEOUT_SECONDS +#: Floor for a budget-derived per-call timeout. Without it, a wait +#: whose deadline has just passed would hand ``subprocess.run`` a +#: non-positive timeout and raise instantly, reporting "tmux is +#: unresponsive" for what is really a normal expiry. +_TMUX_CALL_MIN_SECONDS = 0.25 + + +#: Default line cap for :func:`capture_pane` and similar scrollback +#: readers: a few screens of output, while a pathological pane (50K lines +#: of ``tail -f``) cannot blow an agent's context in one call. Pass +#: ``max_lines=None`` to opt out. +CAPTURE_DEFAULT_MAX_LINES = 500 + + +def _truncate_lines_tail( + lines: list[str], max_lines: int | None +) -> tuple[list[str], bool, int]: + """Return the tail of ``lines`` at most ``max_lines`` long. + + Tail-preserving truncation is required for terminal output: the + most recent lines (active prompt, latest command output) live at + the bottom of the scrollback buffer. Dropping the head keeps what + the agent actually needs. + + Parameters + ---------- + lines : list of str + The captured lines, oldest first. + max_lines : int or None + Maximum number of lines to keep. ``None`` disables truncation. + + Returns + ------- + tuple + ``(kept, truncated, dropped)`` — the kept suffix, whether + truncation happened, and how many lines were dropped. + + Examples + -------- + >>> _truncate_lines_tail(["a", "b", "c"], max_lines=2) + (['b', 'c'], True, 1) + >>> _truncate_lines_tail(["a", "b", "c"], max_lines=5) + (['a', 'b', 'c'], False, 0) + >>> _truncate_lines_tail(["a", "b", "c"], max_lines=None) + (['a', 'b', 'c'], False, 0) + >>> _truncate_lines_tail(["a", "b", "c"], max_lines=0) + Traceback (most recent call last): + libtmux_mcp._errors.ExpectedToolError: max_lines must be at least 1, ... + """ + if max_lines is not None and max_lines < 1: + # Python slices a non-positive cap into nonsense rather than + # failing: ``lines[-0:]`` is the WHOLE list, so max_lines=0 returns + # every row while announcing that all were dropped, and a negative + # inflates the count past the pane's size -- 112 truncated from 12. + # The header is this tool's only disclosure channel. + msg = ( + f"max_lines must be at least 1, or null for no limit (received {max_lines})" + ) + raise ExpectedToolError(msg) + if max_lines is None or len(lines) <= max_lines: + return lines, False, 0 + dropped = len(lines) - max_lines + return lines[-max_lines:], True, dropped + + +def _call_budget(deadline: float | None) -> float: + """Return the per-call tmux timeout, never overshooting ``deadline``. + + A fixed 5 s cap lets a single wedged call run past the caller's + own deadline, and the poll loop issues two reads per tick with the + deadline check only at the end — so a fixed cap makes the true + worst case ``effective_timeout + 2 x 5 s``, not + ``effective_timeout``. Deriving each call's timeout from the + remaining budget collapses that back: the wait cannot exceed its + deadline by more than the floor below. + + The floor keeps a nearly-exhausted budget from passing a zero or + negative timeout to the per-call bound, which would fire + immediately and turn a normal expiry into a spurious "tmux is + unresponsive" error. + """ + if deadline is None: + return _TMUX_CALL_TIMEOUT_SECONDS + remaining = deadline - time.monotonic() + return max(min(_TMUX_CALL_TIMEOUT_SECONDS, remaining), _TMUX_CALL_MIN_SECONDS) + + +async def _run_tmux_lines( + server: Server, *args: str, deadline: float | None = None +) -> list[str]: + """Run one tmux subcommand under a hard wall-clock bound. + + Returns stdout split on newlines with trailing blanks stripped, + matching :class:`libtmux.common.tmux_cmd`'s own normalisation so + call sites see the same shape they did when they went through + libtmux. + + ``deadline`` is a :func:`time.monotonic` reading; when given, the + subprocess timeout is bounded by the budget remaining until it. + + The spawn itself lives in :func:`~libtmux_mcp._tmux_proc._run_tmux_bounded`, + shared with ``wait_for_channel``; see that module for why this path + owns an async subprocess instead of a worker thread. + """ + argv = _tmux_argv(server, *args) + budget = _call_budget(deadline) + try: + returncode, stdout, stderr = await _run_tmux_bounded(argv, timeout=budget) + except TimeoutError as e: + msg = ( + f"tmux {args[0]} did not return within " + f"{budget:.2f}s; the tmux server is unresponsive" + ) + raise ExpectedToolError(msg) from e + if returncode != 0: + detail = stderr.decode(errors="replace").strip() + msg = f"tmux {args[0]} failed: {detail or f'exit {returncode}'}" + raise ExpectedToolError(msg) + out = stdout.decode("utf-8", errors="backslashreplace").split("\n") + while out and out[-1] == "": + out.pop() + return out + + +async def _bounded_pane_state( + server: Server, pane_id: str, *, deadline: float | None = None +) -> _PaneState: + """Read :class:`_PaneState` bounded by the remaining wait budget.""" + out = await _run_tmux_lines( + server, + "display-message", + "-p", + "-t", + pane_id, + PANE_STATE_FORMAT, + deadline=deadline, + ) + return _parse_pane_state(out[0] if out else "0|0|0||0|0") + + +async def _bounded_history_limit( + server: Server, pane_id: str, *, deadline: float | None = None +) -> int: + """Read ``history-limit`` bounded by the remaining wait budget.""" + out = await _run_tmux_lines( + server, + "display-message", + "-p", + "-t", + pane_id, + HISTORY_LIMIT_FORMAT, + deadline=deadline, + ) + return int(out[0]) if out and out[0].isdigit() else 0 + + +async def _bounded_capture( + server: Server, pane_id: str, *, start: int, deadline: float | None = None +) -> list[str]: + """Capture pane rows from ``start`` under a hard timeout. + + ``-J`` joins tmux's visual wraps so a pattern spanning the wrap + column still matches one logical line. ``-p`` prints to stdout. + No caller-supplied text reaches this argv. + """ + return await _run_tmux_lines( + server, + "capture-pane", + "-p", + "-J", + "-t", + pane_id, + "-S", + str(start), + deadline=deadline, + ) + + +async def _resolve_pane_bounded( + server: Server, + *, + pane_id: str | None, + session_name: str | None, + session_id: str | None, + window_id: str | None, + deadline: float | None = None, +) -> str: + """Resolve a pane target natively, without libtmux and without threads. + + libtmux's resolvers are synchronous and reach tmux through + ``Popen.communicate()`` with no timeout. Calling one bare from an + ``async def`` freezes the whole event loop; calling it through + ``asyncio.to_thread`` frees the loop but parks a pool worker that + cannot be cancelled — and + ``concurrent.futures.thread._python_exit`` joins every pool worker + untimed at interpreter shutdown, so a single wedged tmux hangs + process exit and Ctrl-C forever. Neither arrangement is fixable + while a thread is involved, so this reproduces the resolution + against :func:`_run_tmux_lines`, which owns a killable subprocess. + + Mirrors :func:`libtmux_mcp._resolve._resolve_pane` for exactly the + four targeting arguments this tool accepts, including which + argument wins and which exception each miss raises, so the + agent-visible error text is unchanged. + """ + # 1. ``pane_id`` short-circuits everything else. + if pane_id is not None: + rows = await _run_tmux_lines( + server, "list-panes", "-a", "-F", "#{pane_id}", deadline=deadline + ) + if pane_id not in rows: + raise exc.PaneNotFound(pane_id=pane_id) + return pane_id + + # 2. ``window_id`` short-circuits session resolution. + if window_id is not None: + rows = await _run_tmux_lines( + server, "list-windows", "-a", "-F", "#{window_id}", deadline=deadline + ) + matches = [row for row in rows if row == window_id] + if not matches: + raise exc.TmuxObjectDoesNotExist( + obj_key="window_id", + obj_id=window_id, + list_cmd="list-windows", + list_extra_args=("-a",), + ) + if len(matches) > 1: + # ``list-windows -a`` emits a window once per session it is + # linked into, so a unique id can still match twice. libtmux + # raises here rather than guessing, and so must we — silently + # picking the first would be a behaviour change. + raise exc.MultipleObjectsReturned( + count=len(matches), query={"window_id": window_id} + ) + return await _first_pane_of_window(server, window_id, deadline=deadline) + + # 3. ``session_id`` wins over ``session_name``; with neither, the + # first listed session is used. + target_session = await _resolve_session_native( + server, session_name=session_name, session_id=session_id, deadline=deadline + ) + windows = await _run_tmux_lines( + server, + "list-windows", + "-t", + target_session, + "-F", + "#{window_id}", + deadline=deadline, + ) + if not windows: + raise exc.NoWindowsExist + return await _first_pane_of_window( + server, min(windows, key=tmux_id_sort_key), deadline=deadline + ) + + +async def _resolve_session_native( + server: Server, + *, + session_name: str | None, + session_id: str | None, + deadline: float | None, +) -> str: + """Return a session id, mirroring ``_resolve_session``'s precedence.""" + if session_id is not None: + rows = await _run_tmux_lines( + server, "list-sessions", "-F", "#{session_id}", deadline=deadline + ) + if session_id not in rows: + raise exc.TmuxObjectDoesNotExist( + obj_key="session_id", + obj_id=session_id, + list_cmd="list-sessions", + list_extra_args=(), + ) + return session_id + if session_name is not None: + rows = await _run_tmux_lines( + server, + "list-sessions", + "-F", + "#{session_name}\t#{session_id}", + deadline=deadline, + ) + for row in rows: + name, _, sid = row.partition("\t") + if name == session_name: + return sid + raise exc.TmuxObjectDoesNotExist( + obj_key="session_name", + obj_id=session_name, + list_cmd="list-sessions", + list_extra_args=(), + ) + rows = await _run_tmux_lines( + server, "list-sessions", "-F", "#{session_id}", deadline=deadline + ) + if not rows: + raise exc.TmuxObjectDoesNotExist( + obj_key="session", + obj_id="(any)", + list_cmd="list-sessions", + list_extra_args=(), + ) + return min(rows, key=tmux_id_sort_key) + + +async def _first_pane_of_window( + server: Server, window_id: str, *, deadline: float | None +) -> str: + """Return the window's oldest pane, matching ``_resolve_pane``. + + Deliberately not the active pane: the canonical resolver keys on + the immutable id so two untargeted calls agree, and focus is + something any client can move between them. + """ + rows = await _run_tmux_lines( + server, "list-panes", "-t", window_id, "-F", "#{pane_id}", deadline=deadline + ) + if not rows: + raise exc.PaneNotFound + return min(rows, key=tmux_id_sort_key) + + +# --------------------------------------------------------------------------- +# Pattern compilation +# --------------------------------------------------------------------------- diff --git a/src/libtmux_mcp/_caller.py b/src/libtmux_mcp/_caller.py new file mode 100644 index 00000000..18e24d90 --- /dev/null +++ b/src/libtmux_mcp/_caller.py @@ -0,0 +1,351 @@ +"""Which tmux pane, if any, the caller is talking to us from. + +Discovery tools mark the caller's own pane, and the destructive tools +refuse to kill it. `$TMUX` names only the innermost server, so identity +is settled by who is attached rather than by how the nesting arose. +""" + +from __future__ import annotations + +import dataclasses +import logging +import os +import pathlib +import typing as t + +from libtmux import exc +from libtmux.server import Server + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + + +from libtmux_mcp._exec import _LIVENESS_TIMEOUT_SECONDS, _run_tmux_sync + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class CallerIdentity: + """Identity of the tmux pane hosting this MCP server process. + + Parsed from the ``TMUX`` and ``TMUX_PANE`` environment variables that + tmux injects into every child of a pane. ``TMUX`` has the format + ``socket_path,server_pid,session_id`` (see tmux ``environ.c:281``). + + Used to scope self-protection checks to the caller's own tmux server — + a pane ID like ``%1`` is only unique within a single server, so + comparisons must also verify the socket path matches. + + Attributes + ---------- + socket_path : str | None + Filesystem path of the tmux socket the caller is attached to, from + the first ``TMUX`` field. ``None`` when ``TMUX`` is unset or its + first field is empty — the socket comparisons in + :func:`_caller_is_on_server` and + :func:`_caller_is_strictly_on_server` then cannot prove which + server the caller belongs to. + server_pid : int | None + PID of the tmux server process, from the second ``TMUX`` field. + ``None`` when that field is absent or not an integer. + session_id : str | None + Session the caller's pane belongs to (e.g. ``$7``), from the third + ``TMUX`` field. ``None`` when that field is absent or empty. + pane_id : str | None + Pane the MCP server process runs in (e.g. ``%3``), read from + ``TMUX_PANE``. ``None`` when the variable is unset, meaning no + pane can be identified as the caller's own. + """ + + socket_path: str | None + server_pid: int | None + session_id: str | None + pane_id: str | None + + +def _get_caller_identity() -> CallerIdentity | None: + """Return the caller's tmux identity, or None if not inside tmux. + + Reads ``TMUX`` for socket_path/server_pid/session_id and ``TMUX_PANE`` + for the pane id. Tolerant of missing/malformed ``TMUX`` values — + callers should check individual fields rather than relying on all + being populated. + """ + pane_id = os.environ.get("TMUX_PANE") + tmux_env = os.environ.get("TMUX") + + if not tmux_env and not pane_id: + return None + + socket_path: str | None = None + server_pid: int | None = None + session_id: str | None = None + + if tmux_env: + parts = tmux_env.split(",", 2) + if parts: + socket_path = parts[0] or None + if len(parts) >= 2 and parts[1]: + try: + server_pid = int(parts[1]) + except ValueError: + server_pid = None + if len(parts) >= 3 and parts[2]: + session_id = parts[2] + + return CallerIdentity( + socket_path=socket_path, + server_pid=server_pid, + session_id=session_id, + pane_id=pane_id, + ) + + +def _compute_is_caller(pane: Pane) -> bool | None: + """Decide whether ``pane`` is the MCP caller's own tmux pane. + + The returned value is used as the ``is_caller`` annotation on + :class:`~libtmux_mcp.models.PaneInfo`, + :class:`~libtmux_mcp.models.PaneSnapshot`, and + :class:`~libtmux_mcp.models.PaneContentMatch`. + + Tri-state semantics match the original bare-equality check: + + * ``None`` — process is not inside tmux at all (neither ``TMUX`` nor + ``TMUX_PANE`` are set). No caller exists, so the annotation + carries no signal. + * ``True`` — the caller's ``TMUX_PANE`` matches ``pane.pane_id`` + *and* :func:`_caller_is_strictly_on_server` confirms the + caller's socket realpath equals the target's. + * ``False`` — the pane ids differ, or they match but the socket + does not (or cannot be proven to). A bare pane-id equality + check would have returned ``True`` here, which is the + cross-socket false-positive fixed by + tmux-python/libtmux-mcp#19. + + Uses :func:`_caller_is_strictly_on_server` rather than + :func:`_caller_is_on_server`: the kill-guard comparator is + conservative-True-when-uncertain (right for blocking destructive + actions, wrong for an informational annotation that should + demand a positive match). The strict variant declines the + basename fallback, the unresolvable-target branch, and the + socket-path-unset branch so ambiguous cases resolve to ``False``. + """ + caller = _get_caller_identity() + if caller is None or caller.pane_id is None: + return None + return caller.pane_id == pane.pane_id and _caller_is_strictly_on_server( + pane.server, caller + ) + + +def _effective_socket_path(server: Server) -> str | None: + """Return the filesystem socket path a Server will actually use. + + libtmux leaves :attr:`libtmux.Server.socket_path` as ``None`` when only + ``socket_name`` (or neither) was supplied, but tmux still resolves to + a real path under ``${TMUX_TMPDIR:-/tmp}/tmux-/``. This + helper reproduces that resolution so :func:`_caller_is_on_server` can + compare against the caller's ``TMUX`` socket path. + + Resolution order: + + 1. :attr:`libtmux.Server.socket_path` if libtmux already has it. + 2. ``tmux display-message -p '#{socket_path}'`` against the target + server — authoritative because tmux itself reports the path it + is actually using, regardless of our process environment. + Necessary on macOS where ``$TMUX_TMPDIR`` under launchd diverges + from the interactive shell (see ``docs/topics/safety.md`` for + the self-kill guard gap this closes). + 3. Fallback: reconstruct from ``$TMUX_TMPDIR`` + euid + socket name. + This path is reached only when the target server is unreachable + (e.g. not running), in which case no self-kill is possible and + the conservative caller check still blocks via + ``_caller_is_on_server``'s None-socket branch. + """ + if server.socket_path: + return str(server.socket_path) + # ``display-message -p`` prints and exits, so this is cheap. Wrapped + # because the server may be down, the format unsupported on an old + # tmux, or the call denied. + try: + resolved = server.cmd( + "display-message", + "-p", + "#{socket_path}", + ).stdout + except (exc.LibTmuxException, OSError): + resolved = None + if resolved: + first = resolved[0].strip() + if first: + return first + tmux_tmpdir = os.environ.get("TMUX_TMPDIR", "/tmp") + socket_name = server.socket_name or "default" + return str(pathlib.Path(tmux_tmpdir) / f"tmux-{os.geteuid()}" / socket_name) + + +def _target_hosts_the_callers_client(server: Server, caller: CallerIdentity) -> bool: + """Whether a pane on *server* is the terminal the caller lives in. + + ``$TMUX`` names only the INNERMOST server. Run an agent inside tmux, + point it at a second tmux, and its ``$TMUX`` describes the inner one + while the pane actually hosting its terminal belongs to the outer + one -- so every path comparison above says "different server" and a + kill of that pane is permitted. It takes the caller's tty with it, + which is the self-kill this guard exists to prevent. Reproduced on + 3.7c: guard vs the inner server True, vs the outer server False. + + Asking WHO IS ATTACHED answers it without caring how the nesting + arose. A client of the caller's own server occupies a pane of + whatever hosts it, so the inner server's ``client_tty`` is the outer + server's ``pane_tty`` -- measured, both ``/dev/pts/50``. Walking the + process tree instead would only find servers STARTED FROM a pane, + missing one merely attached to, and would need ``/proc``, which + macOS does not have. + + A HUNG probe fails closed, matching the bias of the table above. A + nonzero exit does not: "no such server" is an answer -- a server + that is gone hosts nothing -- and treating it as unknown would block + every destructive call for a caller whose ``$TMUX`` names a socket + that has since died. An empty client list is an answer for the same + reason. + """ + caller_server = Server(socket_path=caller.socket_path) + attached = _run_tmux_sync( + caller_server, + "list-clients", + "-F", + "#{client_tty}", + timeout=_LIVENESS_TIMEOUT_SECONDS, + ) + if attached is None: + return True + if attached.returncode != 0: + # No such server is an ANSWER, not a failure: it cannot be + # hosting us. Distinct from the timeout above, where a wedged + # server might be -- so a dead $TMUX socket cannot block every + # destructive call. + return False + ttys = {line.strip() for line in attached.stdout.splitlines() if line.strip()} + if not ttys: + return False + panes = _run_tmux_sync( + server, + "list-panes", + "-a", + "-F", + "#{pane_tty}", + timeout=_LIVENESS_TIMEOUT_SECONDS, + ) + if panes is None: + return True + if panes.returncode != 0: + return False + return any(line.strip() in ttys for line in panes.stdout.splitlines()) + + +def _caller_is_on_server(server: Server, caller: CallerIdentity | None) -> bool: + """Return True if ``caller`` looks like it is on the same tmux server. + + Compares socket paths via :func:`os.path.realpath` so symlinked temp + dirs still match, then falls back to basename comparison when + realpath disagrees — the authoritative caller-side ``$TMUX`` name + and the target's declared ``socket_name`` are both unaffected by + ``$TMUX_TMPDIR`` divergence (the macOS launchd case), so a + last-chance name match still blocks a self-kill when the path + comparison was fooled by env mismatch. + + Decision table: + + * ``caller is None`` → ``False``. The process isn't inside tmux at + all, so there is no caller-side pane to protect and no self-kill + is possible. + * caller has a pane id but no socket path (e.g. ``TMUX_PANE`` set + without ``TMUX``) → ``True``. We can't rule out that the caller + is on the target server, so err on the side of blocking a + destructive action. + * target server has no resolvable socket path → ``True``. Same + conservative reasoning. + * realpath of caller's socket path matches target's effective path + → ``True`` (primary positive signal). + * basename of caller's socket path equals target's + ``socket_name`` (or ``"default"``) → ``True``. Conservative + last-chance block for env-mismatch scenarios where reconstruction + produced a wrong path but the name was authoritative on both + sides. Trades off one exotic false positive (two daemons with + identical socket_name under different tmpdirs) for a real safety + property. + * a pane on the target server is the terminal the caller's own + server is attached through (nested tmux) → ``True``. ``$TMUX`` + names only the innermost server, so the comparisons above cannot + see this one. + * Otherwise → ``False``. + + When a conservative block is a false positive, the caller's error + message directs the user to run tmux manually. + """ + if caller is None: + return False + if not caller.socket_path: + return caller.pane_id is not None + target = _effective_socket_path(server) + if not target: + return True + try: + if os.path.realpath(caller.socket_path) == os.path.realpath(target): + return True + except OSError: + if caller.socket_path == target: + return True + # Final conservative check: names match even though paths didn't. + # Survives ``$TMUX_TMPDIR`` divergence between the MCP process and + # the caller's shell (macOS launchd). + caller_basename = pathlib.PurePath(caller.socket_path).name + target_name = server.socket_name or "default" + if caller_basename == target_name: + return True + # Nesting: $TMUX names only the innermost server, so none of the + # comparisons above can see a pane on ANOTHER server that is hosting + # this one. + return _target_hosts_the_callers_client(server, caller) + + +def _caller_is_strictly_on_server( + server: Server, caller: CallerIdentity | None +) -> bool: + """Return True only on a confirmed socket-path match. + + Counterpart to :func:`_caller_is_on_server` for the informational + :attr:`~libtmux_mcp.models.PaneInfo.is_caller` annotation. The + destructive-action guard is biased toward True-when-uncertain so a + macOS ``$TMUX_TMPDIR`` divergence cannot fool it into permitting + self-kill; the annotation cannot absorb that bias — ambiguous cases + are exactly the cross-socket false positives documented by + tmux-python/libtmux-mcp#19. This function therefore declines every + branch other than a confirmed ``realpath`` match. + + Decision table: + + * ``caller is None`` → ``False``. No caller identity. + * ``caller.socket_path`` unset (``TMUX_PANE`` set without ``TMUX``) + → ``False``. We cannot verify the caller is on this server. + * target server's effective socket path unresolvable → ``False``. + * ``realpath`` of caller's socket path equals target's effective + path → ``True``. Primary and only positive signal. + * Fallback on ``OSError`` from ``realpath``: exact string match + → ``True``. Still a positive signal, just without the resolve + step. + * Otherwise → ``False`` (including the basename-only match that + :func:`_caller_is_on_server` permits as a conservative block). + """ + if caller is None or not caller.socket_path: + return False + target = _effective_socket_path(server) + if not target: + return False + try: + return os.path.realpath(caller.socket_path) == os.path.realpath(target) + except OSError: + return caller.socket_path == target diff --git a/src/libtmux_mcp/_errors.py b/src/libtmux_mcp/_errors.py new file mode 100644 index 00000000..397336d2 --- /dev/null +++ b/src/libtmux_mcp/_errors.py @@ -0,0 +1,227 @@ +"""The error type every tool raises, and the boundary that shapes it. + +`ExpectedToolError` marks a failure the caller can act on; anything else +is a bug and reaches the client as one. The two decorators put that +distinction at the tool boundary so no tool body repeats it. +""" + +from __future__ import annotations + +import functools +import logging +import typing as t + +from fastmcp.exceptions import ToolError +from libtmux import exc + +logger = logging.getLogger(__name__) + + +class ExpectedToolError(ToolError): + """``ToolError`` for expected, agent-correctable failures. + + Defaults the error's ``log_level`` to ``WARNING`` (honored by + fastmcp >= 3.3 when logging tool/resource failures) so routine + validation errors, missing objects, and tier denials do not surface + as ERROR records. Unexpected failures keep stock :class:`ToolError` + and its ERROR default — those are the ones operators must see. + + Parameters + ---------- + *args : object + Positional arguments forwarded to :class:`ToolError` + (typically the error message). + log_level : int + Level fastmcp's server layer logs this failure at. Defaults + to ``logging.WARNING``. + suggestion : str, optional + Agent-facing recovery hint. + :class:`~libtmux_mcp.middleware.ToolErrorResultMiddleware` + appends it to the error result's text and mirrors it into the + result's ``meta``. + + Examples + -------- + >>> import logging + >>> ExpectedToolError("Pane not found: %5").log_level == logging.WARNING + True + + An explicit level still wins: + + >>> err = ExpectedToolError("noisy", log_level=logging.INFO) + >>> err.log_level == logging.INFO + True + + Catch sites that handle ``ToolError`` keep working — this is a + plain subclass: + + >>> isinstance(ExpectedToolError("x"), ToolError) + True + + An optional ``suggestion`` carries an agent-facing recovery hint; + :class:`libtmux_mcp.middleware.ToolErrorResultMiddleware` surfaces + it in the error result's text and ``meta``: + + >>> err = ExpectedToolError("Pane not found: %5", + ... suggestion="Call list_panes to discover valid pane ids.") + >>> err.suggestion + 'Call list_panes to discover valid pane ids.' + >>> ExpectedToolError("no hint").suggestion is None + True + """ + + def __init__( + self, + *args: object, + log_level: int = logging.WARNING, + suggestion: str | None = None, + ) -> None: + super().__init__(*args, log_level=log_level) + self.suggestion = suggestion + + +P = t.ParamSpec("P") +R = t.TypeVar("R") + + +def _undouble(prefix: str, text: str) -> str: + """Drop *prefix* from *text* when the wrapper is about to add it back.""" + return text.removeprefix(prefix) + + +def _is_format_newline_parse_error(e: BaseException) -> bool: + """Detect libtmux failing to parse a format value containing a newline. + + libtmux <= 0.62.0 splits ``-F`` output one line per object, so a + newline inside any value (a pane's current directory, most reachably) + splits that record and its strict ``zip`` raises. It surfaces as a + bare ``ValueError`` and would otherwise reach the agent as + "Unexpected error", logged at ERROR, naming nothing it can act on. + + Matched on the message because the raise site is a stdlib ``zip`` + with no dedicated exception type. Kept even once the floor moves + past the libtmux fix: the installed version is not ours to choose. + """ + return isinstance(e, ValueError) and "zip()" in str(e) + + +def _map_exception_to_tool_error(fn_name: str, e: BaseException) -> ToolError: + """Translate a libtmux / unexpected exception into a ``ToolError``. + + Shared between the sync and async ``handle_tool_errors*`` decorators + so the two paths stay byte-for-byte identical in what agents see. + + Expected, agent-correctable failures map to + :class:`ExpectedToolError` (logged at WARNING). Two cases stay at + ERROR: a missing tmux binary (operator-environment fault that must + be loud) and the unexpected catch-all (potential bug in this + server). + """ + if isinstance(e, exc.TmuxCommandNotFound): + msg = "tmux binary not found. Ensure tmux is installed and in PATH." + return ToolError(msg) + if isinstance(e, exc.TmuxSessionExists): + return ExpectedToolError(str(e)) + if isinstance(e, exc.BadSessionName): + return ExpectedToolError(str(e)) + if isinstance(e, exc.ObjectDoesNotExist): + return ExpectedToolError( + f"Object not found: {e}", + suggestion=( + "Call list_sessions / list_windows / list_panes to discover valid ids." + ), + ) + if isinstance(e, exc.MultipleObjectsReturned): + return ExpectedToolError( + f"Ambiguous target: {e}", + suggestion=( + "A window shared between sessions is listed once per session that " + "holds it, so a name or index can match more than one row. Target " + "it by id (session_id / window_id / pane_id) instead." + ), + ) + if isinstance(e, exc.PaneNotFound): + return ExpectedToolError( + f"Pane not found: {_undouble('Pane not found: ', str(e))}", + suggestion="Call list_panes to discover valid pane ids.", + ) + if _is_format_newline_parse_error(e): + return ExpectedToolError( + "tmux listing could not be parsed: a format value contains a " + "newline, almost always a pane whose current directory has one " + "in its name. Every pane on this server is affected, not just " + "that one, because pane lookup enumerates them all.", + suggestion=( + "Find it with: tmux list-panes -a -F " + "'#{pane_id} #{pane_current_path}' | cat -A — then move or " + "rename that directory. Upgrading libtmux also fixes it." + ), + ) + if isinstance(e, exc.LibTmuxException): + return ExpectedToolError(f"tmux error: {e}") + logger.exception("unexpected error in MCP tool %s", fn_name) + return ToolError(f"Unexpected error: {type(e).__name__}: {e}") + + +def handle_tool_errors( + fn: t.Callable[P, R], +) -> t.Callable[P, R]: + """Decorate synchronous MCP tool functions with standardized error handling. + + Catches libtmux exceptions and re-raises them through + :func:`_map_exception_to_tool_error` so MCP responses have + ``isError=True`` with a descriptive message — expected, + agent-correctable failures as :class:`ExpectedToolError` (logged + at WARNING), the unexpected catch-all as stock ``ToolError`` + (logged at ERROR). + + The re-raise chains the original exception via ``from e``. Keep it + single-level: :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` + matches :exc:`libtmux.exc.LibTmuxException` by inspecting exactly + one ``__cause__`` hop, so wrapping the mapped error again would + silently disable readonly retries. + + Use :func:`handle_tool_errors_async` for ``async def`` tools — this + wrapper only supports plain sync callables. + """ + + @functools.wraps(fn) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + try: + return fn(*args, **kwargs) + except ToolError: + raise + except Exception as e: + raise _map_exception_to_tool_error(fn.__name__, e) from e + + return wrapper + + +def handle_tool_errors_async( + fn: t.Callable[P, t.Coroutine[t.Any, t.Any, R]], +) -> t.Callable[P, t.Coroutine[t.Any, t.Any, R]]: + """Decorate asynchronous MCP tool functions with standardized error handling. + + Async counterpart to :func:`handle_tool_errors`. Required for tools + that accept a :class:`fastmcp.Context` parameter because Context's + ``report_progress``/``elicit``/``read_resource`` methods are + coroutines that only run inside ``async def`` tools. + + Maps the same libtmux exception set to the same messages and + error classes as the sync decorator (expected failures as + :class:`ExpectedToolError` at WARNING, the unexpected catch-all as + stock ``ToolError`` at ERROR) by delegating to a shared helper, + and chains the original exception via the same single-level + ``from e`` that readonly retries depend on. + """ + + @functools.wraps(fn) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + try: + return await fn(*args, **kwargs) + except ToolError: + raise + except Exception as e: + raise _map_exception_to_tool_error(fn.__name__, e) from e + + return wrapper diff --git a/src/libtmux_mcp/_exec.py b/src/libtmux_mcp/_exec.py new file mode 100644 index 00000000..3b0855d2 --- /dev/null +++ b/src/libtmux_mcp/_exec.py @@ -0,0 +1,283 @@ +"""Reaching the tmux binary: argv, wall-clock bounds, and exec failures. + +libtmux's `tmux_cmd` calls `Popen.communicate()` with no timeout, so a +wedged server blocks its caller forever. `_BoundedTmuxCmd` replaces it +process-wide with the same interface under a bound. +""" + +from __future__ import annotations + +import errno +import importlib +import logging +import shlex +import shutil +import subprocess +import typing as t + +from libtmux import exc +from libtmux.common import tmux_cmd +from libtmux.server import Server + +from libtmux_mcp._errors import ExpectedToolError + +logger = logging.getLogger(__name__) + + +#: Linux caps one argv element at 32 pages regardless of total argv +#: size. Reported in the error rather than enforced: the OS is the +#: authority, and predicting it would drift from the platform. +_MAX_ARG_STRLEN = 131072 + + +def _raise_tmux_exec_error(err: OSError, argv: list[str]) -> t.NoReturn: + """Re-raise an exec-time ``OSError`` as a caller-correctable failure. + + Every ``subprocess.run`` here catches ``TimeoutExpired`` and + ``CalledProcessError`` -- both of which mean tmux RAN. An argv that + never reaches tmux fails earlier and differently, and the raw + ``OSError`` then surfaced as "Unexpected error", which reads as a + server defect rather than as oversized input. + + ``E2BIG`` is the one an agent can hit with ordinary input: Linux + caps a SINGLE argv element at ``MAX_ARG_STRLEN`` (32 pages, 131072 + bytes), independently of the total. Measured, the boundary is + exact -- 131071 bytes reaches tmux and is rejected with ``command + too long``, 131072 fails in ``execve``. Both mean "too big", but + only one of them used to say so. + + Parameters + ---------- + err : OSError + The exec failure. + argv : list of str + The command vector, used to report which argument was too long. + + Raises + ------ + ExpectedToolError + For a failure the caller can correct. + OSError + Re-raised unchanged when it is not one of those. + """ + if err.errno == errno.E2BIG: + longest = max((len(a) for a in argv), default=0) + msg = ( + f"tmux {_tmux_subcommand(argv)} argument is too large to pass to " + f"the OS ({longest} bytes; the limit is {_MAX_ARG_STRLEN} per " + "argument). Use paste_text, which routes through a tmux buffer " + "instead of argv and has no comparable limit." + ) + raise ExpectedToolError(msg) from err + if isinstance(err, FileNotFoundError): + raise exc.TmuxCommandNotFound from err + raise err + + +#: How long any single tmux call may take before this server calls the +#: tmux server unresponsive. Generous: a responsive tmux answers in +#: single-digit milliseconds, and capturing a 200,000-line history -- +#: the slowest operation constructible here -- measured 37ms. +#: +#: THE one definition of that policy. Every other bound derives from it +#: rather than repeating the literal, because a second copy does not +#: fail when this one moves; the tools just start disagreeing. +_LIVENESS_TIMEOUT_SECONDS = 5.0 + + +def _run_tmux_sync( + server: Server, *tmux_args: str, timeout: float +) -> subprocess.CompletedProcess[str] | None: + """Run one tmux command with a hard bound, or ``None`` if it hung. + + SYNCHRONOUS, and named so: the async path must not use this or a + thread wrapper around it. See :mod:`libtmux_mcp._tmux_proc`. + + A socket with a listener is not a server that answers. A tmux server + spinning inside its own event loop accepts the connection and never + replies, and ``Server.cmd`` has no timeout -- so ONE such socket in + ``$TMUX_TMPDIR`` made ``list_servers`` never return. Measured: the + same scan took 2.03s before a silent listener was added to the + directory and had not finished 85 seconds after. + + The child is killed on timeout, so an abandoned probe does not leave + a tmux client behind. + """ + try: + return subprocess.run( + _tmux_argv(server, *tmux_args), + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + except (subprocess.TimeoutExpired, OSError): + return None + + +def _tmux_argv(server: Server, *tmux_args: str) -> list[str]: + """Build a full tmux argv list honouring ``socket_name`` and ``socket_path``. + + Internal helper shared by every module that has to invoke the tmux + binary directly via :func:`subprocess.run` (the buffer, wait-for, + and paste_text tools). libtmux's own :meth:`libtmux.Server.cmd` wraps the + same logic but does not expose a timeout, so tools that need + bounded blocking have to shell out themselves — and when they do + they must honour the caller's socket. + + Parameters + ---------- + server : libtmux.server.Server + The resolved server whose socket to target. + *tmux_args : str + tmux subcommand and its flags, e.g. ``"load-buffer", "-b", name``. + + Returns + ------- + list[str] + Complete argv ready for :func:`subprocess.run`. + + Examples + -------- + >>> class _S: + ... tmux_bin = "tmux" + ... socket_name = "s" + ... socket_path = None + >>> _tmux_argv(t.cast("Server", _S()), "list-sessions") + ['tmux', '-L', 's', 'list-sessions'] + + >>> class _P: + ... tmux_bin = "tmux" + ... socket_name = None + ... socket_path = "/tmp/tmux-1000/default" + >>> _tmux_argv(t.cast("Server", _P()), "ls") + ['tmux', '-S', '/tmp/tmux-1000/default', 'ls'] + """ + tmux_bin: str = getattr(server, "tmux_bin", None) or "tmux" + argv: list[str] = [tmux_bin] + if server.socket_name: + argv.extend(["-L", server.socket_name]) + if server.socket_path: + argv.extend(["-S", str(server.socket_path)]) + argv.extend(tmux_args) + return argv + + +#: Hard bound on ONE tmux call made through a synchronous tool. Same +#: value as the liveness probe's budget, and safe to be wrong for the +#: same reason: expiry yields a disclosed error naming the subcommand, +#: never a confident wrong answer, so a too-small value cannot mislead. +_SYNC_CALL_TIMEOUT_SECONDS = _LIVENESS_TIMEOUT_SECONDS + + +#: Stock ``tmux_cmd`` logs on this logger and callers read dispatched +#: argv off its records, so the bounded replacement must use it too +#: rather than its own module logger. +_LIBTMUX_COMMON_LOGGER = logging.getLogger("libtmux.common") + + +def _tmux_subcommand(argv: list[str]) -> str: + """Name the tmux subcommand in an argv, for error messages. + + ``argv[1]`` may be ``-Lname``: libtmux joins the socket flag to its + value, so the first non-flag element is the subcommand. + """ + for part in argv[1:]: + if not part.startswith("-"): + return part + return "command" + + +class _BoundedTmuxCmd(tmux_cmd): + """A ``tmux_cmd`` that cannot outlive its timeout. + + libtmux runs every tmux command through an untimed + ``Popen.communicate()`` (``libtmux/common.py``), so a server that + accepts the connection and then says nothing hangs its caller + forever. A tool's liveness probe bounds only the FIRST round trip: + ``break_pane`` makes eleven and held for 150s at the second. + + Bounding at ``tmux_cmd`` rather than ``Server.cmd`` is deliberate. + ``Server.cmd`` is not the only funnel -- ``neo.fetch_objs`` builds a + ``tmux_cmd`` directly, and that is the path behind ``window.panes`` + and ``session.windows``, so a ``Server`` subclass leaves the busiest + caller unbounded. See :func:`_install_bounded_tmux_cmd`. + """ + + def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: + resolved = tmux_bin or shutil.which("tmux") + if not resolved: + raise exc.TmuxCommandNotFound + argv = [resolved, *(str(arg) for arg in args)] + self.cmd = argv + # A contract, not decoration: callers read the argv of every + # dispatched command out of these two records, so replacing + # __init__ without them blinds that silently. + emit_debug = _LIBTMUX_COMMON_LOGGER.isEnabledFor(logging.DEBUG) + cmd_str = shlex.join(argv) if emit_debug else "" + if emit_debug: + _LIBTMUX_COMMON_LOGGER.debug( + "tmux command dispatched", extra={"tmux_cmd": cmd_str} + ) + try: + completed = subprocess.run( + argv, + capture_output=True, + text=True, + check=False, + timeout=_SYNC_CALL_TIMEOUT_SECONDS, + encoding="utf-8", + errors="backslashreplace", + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + except subprocess.TimeoutExpired as err: + # NOT a LibTmuxException: Server._fetch_or_empty catches those + # and returns [] for a not-yet-started daemon, which would + # report a WEDGED server as having no sessions. subprocess.run + # kills and reaps before raising, so no tmux client is left. + msg = ( + f"tmux {_tmux_subcommand(argv)} did not return within " + f"{_SYNC_CALL_TIMEOUT_SECONDS:.2f}s; the tmux server is unresponsive" + ) + raise ExpectedToolError(msg) from err + + self.returncode = completed.returncode + stdout_split = (completed.stdout or "").split("\n") + while stdout_split and stdout_split[-1] == "": + stdout_split.pop() + self.stderr = list(filter(None, (completed.stderr or "").split("\n"))) + # libtmux surfaces has-session's failure through stdout; mirrored + # so Server.has_session reads the same either way. + if "has-session" in argv and self.stderr and not stdout_split: + self.stdout = [self.stderr[0]] + else: + self.stdout = stdout_split + + if emit_debug: + _LIBTMUX_COMMON_LOGGER.debug( + "tmux command completed", + extra={ + "tmux_cmd": cmd_str, + "tmux_exit_code": self.returncode, + "tmux_stdout": self.stdout[:100], + "tmux_stderr": self.stderr[:100], + "tmux_stdout_len": len(self.stdout), + "tmux_stderr_len": len(self.stderr), + }, + ) + + +#: Every libtmux module that constructs a ``tmux_cmd``. Each resolves the +#: name as a module global at call time, so rebinding it here bounds the +#: call. A test AST-walks the installed libtmux so a new call site in a +#: new module fails loudly rather than reintroducing an unbounded path. +_PATCHED_LIBTMUX_MODULES = ("libtmux.common", "libtmux.neo", "libtmux.server") + + +def _install_bounded_tmux_cmd() -> None: + """Point libtmux's ``tmux_cmd`` references at the bounded subclass.""" + for name in _PATCHED_LIBTMUX_MODULES: + module = importlib.import_module(name) + if getattr(module, "tmux_cmd", None) is not _BoundedTmuxCmd: + module.tmux_cmd = _BoundedTmuxCmd # type: ignore[attr-defined] diff --git a/src/libtmux_mcp/_filters.py b/src/libtmux_mcp/_filters.py new file mode 100644 index 00000000..d2396591 --- /dev/null +++ b/src/libtmux_mcp/_filters.py @@ -0,0 +1,359 @@ +"""Django-style field lookups over libtmux QueryLists. + +tmux reports every field as text, so a filter value is coerced to the +shape tmux would have reported before it is compared. +""" + +from __future__ import annotations + +import dataclasses +import difflib +import functools +import json +import logging +import typing as t + +from libtmux._internal.query_list import LOOKUP_NAME_MAP, QueryList + +if t.TYPE_CHECKING: + from pydantic import BaseModel + + +from libtmux_mcp._errors import ExpectedToolError + +logger = logging.getLogger(__name__) + + +M = t.TypeVar("M") + + +def _coerce_dict_arg( + name: str, + value: dict[str, t.Any] | str | None, +) -> dict[str, t.Any] | None: + """Coerce a tool parameter to a dict, accepting JSON-string form. + + Workaround: Cursor's composer-1/composer-1.5 models and some other + MCP clients serialize dict params as JSON strings instead of + objects. Claude and GPT models through Cursor work fine; the bug + is model-specific. This helper is the canonical place to absorb + the string form so each tool can stay dict-typed on the Python + side. Callers pass ``name`` so the error messages identify the + offending parameter. + + See: + https://forum.cursor.com/t/145807 + https://github.com/anthropics/claude-code/issues/5504 + + Parameters + ---------- + name : str + Parameter name, used in error messages. + value : dict, str, or None + Either an already-decoded dict, a JSON string of a dict, or + ``None``. + + Returns + ------- + dict or None + The decoded dict, or ``None`` if the input was ``None`` or an + empty string. + + Raises + ------ + ExpectedToolError + If ``value`` is a string that is not valid JSON, or decodes to + a JSON value that is not an object. + """ + if value is None or value == "": + return None + if isinstance(value, str): + try: + decoded = json.loads(value) + except (json.JSONDecodeError, ValueError) as e: + msg = f"Invalid {name} JSON: {e}" + raise ExpectedToolError(msg) from e + if not isinstance(decoded, dict): + msg = f"{name} must be a JSON object, got {type(decoded).__name__}" + raise ExpectedToolError(msg) from None + return decoded + return value + + +@functools.cache +def _filterable_fields(obj_type: type) -> frozenset[str]: + """Attribute names a filter key may begin with. + + ``QueryList`` resolves a key by ``getattr`` traversal and treats a + miss as "no match", so an unknown field silently filters every row + out and an empty result is indistinguishable from a typo. + + Deliberately permissive: it rejects names the type cannot have and + accepts everything else, because ``__`` traversal into a nested + object is legitimate and only the first segment is checkable here. + """ + names = {name for name in dir(obj_type) if not name.startswith("_")} + if dataclasses.is_dataclass(obj_type): + names |= {field.name for field in dataclasses.fields(obj_type)} + return frozenset(names) + + +_MODEL_FIELD_ALIASES: dict[str, str] = { + "window_count": "session_windows", + "pane_count": "window_panes", + "active_pane_id": "active_pane__pane_id", +} +"""Output fields tmux exposes under a different attribute name.""" + + +def _admits_bool(annotation: t.Any) -> bool: + """Whether a model field's annotation can hold a bool.""" + return annotation is bool or bool in t.get_args(annotation) + + +_BOOL_TRUE = frozenset({"true", "1", "yes"}) +_BOOL_FALSE = frozenset({"false", "0", "no"}) + +#: Operators that mean anything against a bool. The rest are string or +#: collection tests, and libtmux's lookups fall through to ``False`` for +#: a bool -- so allowing them answers every query with an empty list, +#: contradictory pairs like ``__in``/``__nin`` included. +_BOOL_OPERATORS = frozenset({"exact", "eq"}) + + +def _coerce_model_value(key: str, value: t.Any, annotation: t.Any) -> t.Any: + """Coerce a filter value to what the model field actually holds. + + ``filters`` is typed ``dict[str, str]``, so a bool field is + addressed as ``"true"``; comparing that to ``True`` never matches. + An unrecognised token is rejected rather than compared as a string, + which would report "nothing matched" for a typo. + """ + if isinstance(value, str) and _admits_bool(annotation): + lowered = value.strip().lower() + if lowered in _BOOL_TRUE: + return True + if lowered in _BOOL_FALSE: + return False + msg = ( + f"Filter '{key}' takes a boolean, got {value!r}. Use one of: " + f"{', '.join(sorted(_BOOL_TRUE | _BOOL_FALSE))}." + ) + raise ExpectedToolError(msg) + return value + + +def _path_resolves(item: t.Any, path: str) -> bool: + """Whether ``path``'s ``__``-separated segments resolve on ``item``. + + ``None`` ends the walk only at an INTERMEDIATE segment, where there + is genuinely nothing to traverse into. On the terminal segment it is + an ordinary value -- tmux leaves many format fields empty, so + ``active_pane__pane_start_command`` is None on every shell pane -- + and treating that as unresolvable turns a true empty result into a + false error. + """ + current = item + segments = path.split("__") + last = len(segments) - 1 + for i, segment in enumerate(segments): + try: + current = getattr(current, segment) + except Exception: # noqa: BLE001 - any failure means "no such path" + return False + if current is None: + return i == last + return True + + +def _attribute_access_error(probe: list[t.Any], field: str) -> str | None: + """Message if ``field`` raises on every probed item, else ``None``. + + libtmux keeps removed properties around so they raise a message + naming the replacement. ``dir()`` still lists them, so they reach + callers as filterable; ``QueryList`` swallows the raise and answers + an empty list. Surfacing libtmux's own message is what makes the + refusal useful. + + One item settles it: the raise comes from the class, so it cannot + differ per instance. + """ + if not probe: + return None + try: + getattr(probe[0], field) + except Exception as exc: # noqa: BLE001 - reported, not handled + return str(exc) + return None + + +def _unknown_field_message( + key: str, + field: str, + allowed_fields: frozenset[str], + model_fields: t.Mapping[str, t.Any], + obj_type: type, +) -> str: + """Build the error for a filter key naming no known field.""" + msg = f"Unknown filter field '{field}' in '{key}'." + known = sorted(set(allowed_fields) | set(model_fields)) + close = difflib.get_close_matches(field, known, n=3) + if close: + msg += f" Did you mean: {', '.join(close)}?" + return ( + f"{msg} Every field this tool returns is filterable: " + f"{', '.join(sorted(model_fields))}. libtmux " + f"{obj_type.__name__} attributes are accepted too, though tmux " + "leaves many of them empty." + ) + + +def _raise_if_path_unresolvable( + probe: list[t.Any], + field_path: str, + key: str, + valid_ops: list[str], + *, + operator_parsed: bool, +) -> None: + """Reject a multi-segment path no item can resolve. + + Guards the traversal fallback: without this, a mistyped operator + (``session_name__containss``) reads as a path, resolves on nothing + and filters every row out -- the silent-empty answer this module + exists to prevent. Only provable when something is there to probe, + so an empty list is left alone. + """ + if "__" not in field_path: + return + if not probe or any(_path_resolves(item, field_path) for item in probe): + return + msg = f"Filter '{key}' names no attribute path on any item." + if not operator_parsed: + # Only a key with no operator can be a mistyped one. When an + # operator WAS parsed off, blaming the last path segment for + # not being one denies the operator the caller supplied. + trailing = field_path.rsplit("__", 1)[1] + close = difflib.get_close_matches(trailing, valid_ops, n=3) + msg += ( + f" '{trailing}' is not a filter operator either; did you mean '{close[0]}'?" + if close + else f" '{trailing}' is not a filter operator either." + ) + raise ExpectedToolError(msg) + + +def _as_tmux_text(value: str | bool | int) -> str | bool | int: + """Render a typed filter value the way tmux reports the field. + + tmux-derived attributes are always STRINGS -- ``pane_width`` is + ``"80"``, ``pane_active`` is ``"1"``. Comparing them against a real + ``80`` or ``True`` matches nothing, so accepting typed values in the + schema without this would trade a validation error for a confident + empty result. Booleans first: ``bool`` is a subclass of ``int``. + """ + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, int): + return str(value) + return value + + +def _apply_filters( + items: t.Any, + filters: dict[str, str | bool | int] | str | None, + serializer: t.Callable[..., M], + obj_type: type, + model_type: type[BaseModel], +) -> list[M]: + """Apply QueryList filters and serialize results. + + Parameters + ---------- + items : QueryList + The QueryList of tmux objects to filter. + filters : dict or str, optional + Django-style filters as a dict (e.g. ``{"session_name__contains": "dev"}``) + or as a JSON string. Some MCP clients require the string form. + If None or empty, all items are returned. + serializer : callable + Serializer function to convert each item to a model. + obj_type : type + libtmux class of the filtered items, used to validate filter + field names. Taken as a parameter rather than read off the + first item so an empty list still validates -- an empty result + is exactly when a typo most needs reporting. + model_type : type + Model ``serializer`` returns. Its fields are filterable too, so + that filtering by what a listing displayed always works. + + Returns + ------- + list + Serialized list of matching items. + + Raises + ------ + ExpectedToolError + If a filter key uses an invalid lookup operator or names a + field the object cannot have. + """ + coerced = _coerce_dict_arg("filters", filters) + if not coerced: + return [serializer(item) for item in items] + filters = coerced + + valid_ops = sorted(LOOKUP_NAME_MAP.keys()) + allowed_fields = _filterable_fields(obj_type) + model_fields = model_type.model_fields + attr_filters: dict[str, t.Any] = {} + model_filters: dict[str, t.Any] = {} + probe = list(items) + + for key, value in filters.items(): + # Matching QueryList: an unknown trailing segment is part of the + # attribute path with the operator defaulting to ``exact``, so + # ``active_pane__pane_id`` traverses. + field_path, op = key, "" + if "__" in key: + lhs, trailing = key.rsplit("__", 1) + if trailing in LOOKUP_NAME_MAP: + field_path, op = lhs, trailing + + field = field_path.split("__", 1)[0] + if field in allowed_fields: + removed = _attribute_access_error(probe, field) + if removed is not None: + msg = f"Filter field '{field}' cannot be read: {removed}" + raise ExpectedToolError(msg) + _raise_if_path_unresolvable( + probe, field_path, key, valid_ops, operator_parsed=bool(op) + ) + attr_filters[key] = _as_tmux_text(value) + elif field in _MODEL_FIELD_ALIASES: + attr_filters[_MODEL_FIELD_ALIASES[field] + key[len(field) :]] = ( + _as_tmux_text(value) + ) + elif field in model_fields: + annotation = model_fields[field].annotation + if _admits_bool(annotation) and op and op not in _BOOL_OPERATORS: + msg = ( + f"Operator '{op}' does not apply to boolean field " + f"'{field}'. Use {' or '.join(sorted(_BOOL_OPERATORS))}, " + "or omit the operator." + ) + raise ExpectedToolError(msg) + # Computed server-side, so it exists only after serializing. + model_filters[key] = _coerce_model_value(key, value, annotation) + else: + raise ExpectedToolError( + _unknown_field_message( + key, field, allowed_fields, model_fields, obj_type + ) + ) + + filtered = items.filter(**attr_filters) if attr_filters else items + results = [serializer(item) for item in filtered] + if model_filters: + results = list(QueryList(results).filter(**model_filters)) + return results diff --git a/src/libtmux_mcp/_guards.py b/src/libtmux_mcp/_guards.py new file mode 100644 index 00000000..6e03973b --- /dev/null +++ b/src/libtmux_mcp/_guards.py @@ -0,0 +1,271 @@ +"""Preconditions a tool refuses on, before tmux is reached. + +Each guard names the argument and the consequence, so a caller learns +what to send instead rather than reading a tmux parse error. +""" + +from __future__ import annotations + +import logging +import os +import pathlib +import re +import shlex +import shutil +import typing as t + +from libtmux import exc + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + + +from libtmux_mcp._errors import ExpectedToolError + +logger = logging.getLogger(__name__) + + +#: POSIX portable environment variable name. +_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _raise_if_untargeted(tool: str, **targets: str | None) -> None: + """Refuse a call that delivers input without saying where. + + Reads may default; a tool that types into a pane may not. The + default was the first LISTED object, which tmux orders by name, so + ``rename_session`` moved where an untargeted ``send_keys`` landed -- + keystrokes into a pane belonging to a session the caller had never + touched. Keying the default on the tmux id makes it stable, but + stable is not the same as correct: nothing about the call says + which pane was meant. + + The precedent is in this same server. ``kill_window`` requires + ``window_id``, so the destructive tools already refuse to guess. + There is no principled reason ``send_keys`` gets to, and it is the + one that executes something. + + The destination is disclosed in the result today, which is not the + same as a guard: it arrives after the keystrokes have landed. + """ + if any(value is not None for value in targets.values()): + return + msg = ( + f"{tool} requires an explicit target: pass " + f"{', '.join(sorted(targets))}. It delivers input to a pane, so " + "there is no safe default -- the pane it would have picked " + "belongs to whichever session is oldest, which is unrelated to " + "what the call is for. Use list_panes or search_panes to find " + "the pane, and snapshot_pane to confirm what it is running." + ) + raise ExpectedToolError(msg) + + +def _raise_if_flag_like(label: str, value: str) -> None: + """Refuse a caller string tmux would parse as a flag. + + tmux reads flags before quoting can protect anything, and libtmux + emits ``[name, value]`` with no ``--`` terminator. So a leading + ``-`` substitutes one command for another silently: measured, + ``set_environment(name="-u", value="VICTIM")`` UNSET ``VICTIM`` and + reported ``status="set"``, and ``set_option(option="-g", value="x")`` + turned off ``xterm-keys`` because tmux prefix-matched ``x``. + """ + if value.startswith("-"): + msg = ( + f"{label} may not begin with '-': tmux parses it as a flag, so " + f"the call would run a different command than the one requested " + f"(got {value!r})." + ) + raise ExpectedToolError(msg) + + +def _raise_if_not_env_name(name: str) -> None: + """Refuse an environment variable name tmux or POSIX cannot hold.""" + if not _ENV_NAME_RE.match(name): + msg = ( + f"Environment variable name must match [A-Za-z_][A-Za-z0-9_]* " + f"(got {name!r}). tmux stores anything else verbatim as an " + "unusable name, and a leading '-' is read as a flag." + ) + raise ExpectedToolError(msg) + + +#: Characters that make a spawn command a shell PROGRAM rather than a bare +#: invocation. tmux hands a one-argument command to ``$SHELL -c`` +#: (``spawn.c``: "If one argument, pass it to $SHELL -c"), so anything sh +#: interprets is beyond a pre-flight's reach. +_SHELL_METACHARACTERS = frozenset(";&|<>()$`\\\"'\n\t*?[]{}~#=!") + +#: Words that legitimately begin a command and are never found on PATH. +#: Without these the pre-flight refuses ``exec sleep 60`` and ``cd /tmp``, +#: which sh runs perfectly well. +_SHELL_BUILTINS = frozenset( + ( + ".", + ":", + "alias", + "bg", + "break", + "case", + "cd", + "command", + "continue", + "do", + "done", + "elif", + "else", + "esac", + "eval", + "exec", + "exit", + "export", + "false", + "fc", + "fg", + "fi", + "for", + "function", + "getopts", + "hash", + "if", + "in", + "jobs", + "kill", + "local", + "newgrp", + "pwd", + "read", + "readonly", + "return", + "select", + "set", + "shift", + "source", + "test", + "then", + "time", + "times", + "trap", + "true", + "type", + "ulimit", + "umask", + "unalias", + "unset", + "until", + "wait", + "while", + ) +) + + +def _unrunnable_spawn_program(shell: str) -> str | None: + """Return the program tmux certainly cannot run, else ``None``. + + ``None`` covers both "this will run" and "no pre-flight can tell", + and the two are deliberately not distinguished: the only safe + refusal is one that cannot be wrong. + + Anything sh interprets is undecidable, because tmux passes a + one-argument command to ``$SHELL -c`` rather than exec'ing it. + Measured: ``cd /tmp && sleep 60``, ``VAR=1 sleep 60`` and + ``exec sleep 60`` all run, and an earlier version of this check + refused all three while asserting the pane would die. + """ + if _SHELL_METACHARACTERS & set(shell): + return None + try: + program = shlex.split(shell)[0] + except (ValueError, IndexError): + return None + if program in _SHELL_BUILTINS: + return None + if "/" in program: + return None if os.access(program, os.X_OK) else program + return None if shutil.which(program) is not None else program + + +def _raise_if_shell_unrunnable(shell: str | None, *, consequence: str) -> None: + """Refuse a spawn command whose program cannot be executed. + + Checked BEFORE spawning because the failure is destructive rather + than merely wrong: tmux reports success, the new process dies, and + the pane goes with it. Catching it afterwards can only report the + loss, and even that races the doomed process. + """ + if not shell: + return + program = _unrunnable_spawn_program(shell) + if program is None: + return + msg = f"{program!r} is not an executable command. {consequence}" + raise ExpectedToolError(msg) + + +def _raise_if_start_directory_unusable(start_directory: str | None) -> None: + """Refuse a start directory the spawned pane could not actually use. + + tmux never reports this. ``spawn.c`` tries ``chdir(cwd)``, then + ``chdir($HOME)``, then ``chdir("/")``, and succeeds either way -- so + a typo, a flag-shaped value or an unexpanded ``~`` puts the pane in + the home directory while the caller is told otherwise. Measured on + ``create_session``, ``split_window`` and ``create_window``: six + unusable values, zero errors, every pane in ``$HOME``. + + ``None`` means "not specified" and inherits normally. An empty + string does not: tmux then takes the client's cwd, which is the MCP + server's own working directory and has nothing to do with the + caller. + """ + if start_directory is None: + return + if ( + start_directory + and pathlib.Path(start_directory).is_dir() + and os.access(start_directory, os.X_OK) + ): + return + expanded = str(pathlib.Path(start_directory).expanduser()) + if expanded != start_directory and pathlib.Path(expanded).is_dir(): + hint = f" tmux does not expand '~' -- pass {expanded!r}." + elif not start_directory: + hint = ( + " An empty string is not the same as omitting the argument: " + "tmux would use the MCP server's own working directory." + ) + else: + hint = "" + msg = ( + f"start_directory {start_directory!r} is not a usable directory. " + f"tmux reports no error for this -- it falls back to $HOME, then " + f"to '/', so the pane would start somewhere that was never " + f"requested.{hint}" + ) + raise ExpectedToolError(msg) + + +def _raise_spawned_pane_gone(shell: str | None) -> t.NoReturn: + """Report a spawn that tmux accepted and then had nothing to show for.""" + detail = f" running {shell!r}" if shell else "" + msg = ( + f"The new pane{detail} exited immediately and tmux removed it, so " + "there is no pane to return. tmux reports a split like this as " + "successful." + ) + raise ExpectedToolError(msg) from None + + +def _raise_if_spawned_pane_is_gone(pane: Pane, shell: str | None) -> None: + """Refuse to report a pane the spawn has already destroyed. + + The pre-flight cannot cover this on its own: anything sh interprets + is undecidable in advance, and ``#{session_name}`` reaches sh as a + comment, so the pane exits 0 and disappears. tmux still reports + success. Measured: ``refresh()`` raises at t+0, so the vanished pane + is observable immediately rather than racily. + """ + try: + pane.refresh() + except exc.TmuxObjectDoesNotExist: + _raise_spawned_pane_gone(shell) diff --git a/src/libtmux_mcp/_history.py b/src/libtmux_mcp/_history.py index 1c42bf0c..c7377fe5 100644 --- a/src/libtmux_mcp/_history.py +++ b/src/libtmux_mcp/_history.py @@ -78,7 +78,8 @@ def _prepare_spawn_environment( If keys or values are not strings, or a caller value conflicts with a required history control. """ - from libtmux_mcp._utils import ExpectedToolError, _coerce_dict_arg + from libtmux_mcp._errors import ExpectedToolError + from libtmux_mcp._filters import _coerce_dict_arg coerced = _coerce_dict_arg("environment", environment) if coerced is None: diff --git a/src/libtmux_mcp/tools/pane_tools/state.py b/src/libtmux_mcp/_pane_state.py similarity index 59% rename from src/libtmux_mcp/tools/pane_tools/state.py rename to src/libtmux_mcp/_pane_state.py index d0220aeb..406c0db7 100644 --- a/src/libtmux_mcp/tools/pane_tools/state.py +++ b/src/libtmux_mcp/_pane_state.py @@ -1,10 +1,14 @@ -"""Shared tmux pane state helpers for read and wait tools.""" +"""Pane grid and lifecycle state, read in one tmux round trip. + +Parsing only: no tool lives here, so the bounded-IO layer and the pane +tools can both depend on it without depending on each other. +""" from __future__ import annotations import typing as t -from libtmux_mcp._utils import ExpectedToolError +from libtmux_mcp._errors import ExpectedToolError if t.TYPE_CHECKING: from libtmux.pane import Pane @@ -20,8 +24,8 @@ class _PaneState(t.NamedTuple): Wire format parsed by :func:`_read_pane_state`:: - #{history_size}|#{cursor_y}|#{pane_height}|#{pane_pid}|#{pane_dead} - |#{alternate_on} + #{history_size}|#{cursor_y}|#{pane_height}|#{pane_width} + |#{pane_in_mode}|#{pane_pid}|#{pane_dead}|#{alternate_on} Fields are ``|``-separated: the first three are non-negative integers, ``pane_pid`` is a decimal PID string, and ``pane_dead`` @@ -40,45 +44,62 @@ class _PaneState(t.NamedTuple): history_size: int cursor_y: int pane_height: int + pane_width: int + in_mode: bool pane_pid: str pane_dead: bool alternate_on: bool = False -#: tmux format string read by :func:`_read_pane_state`. Exposed as a -#: constant because the wait tools re-issue the identical read through -#: a timeout-bounded ``subprocess.run`` rather than libtmux (whose -#: ``Popen.communicate()`` has no timeout and can wedge a worker -#: thread). It is a fixed literal — no caller-supplied text is ever -#: interpolated into a tmux format string, because tmux's format -#: parser treats ``#`` and ``}`` structurally and a pattern containing -#: either silently corrupts the surrounding fields. +#: tmux format string read by :func:`_read_pane_state`. A constant because +#: the wait tools re-issue the identical read through a timeout-bounded +#: ``subprocess.run`` rather than libtmux, whose ``Popen.communicate()`` +#: has no timeout. A fixed literal: caller text is never interpolated into +#: a tmux format, since the parser treats ``#`` and ``}`` structurally and +#: either one silently corrupts the surrounding fields. PANE_STATE_FORMAT = ( - "#{history_size}|#{cursor_y}|#{pane_height}|#{pane_pid}|#{pane_dead}" - "|#{alternate_on}" + "#{history_size}|#{cursor_y}|#{pane_height}|#{pane_width}" + "|#{pane_in_mode}|#{pane_pid}|#{pane_dead}|#{alternate_on}" ) #: ``history-limit`` read, split out for the same reason. HISTORY_LIMIT_FORMAT = "#{history_limit}" +def _int_or_zero(value: str) -> int: + """Parse a tmux numeric format field, treating a missing value as 0. + + A dead pane makes ``display-message`` expand every field to the + empty string, so a bare ``int()`` raised + ``ValueError: invalid literal for int() with base 10: ''`` from the + hot poll path -- a wait whose pane died reported a raw parse crash + rather than ``pane_dead``. Same degrade-don't-fail rule the + ``alternate_on`` comment below states. + """ + return int(value) if value else 0 + + def _parse_pane_state(raw: str) -> _PaneState: """Parse one :data:`PANE_STATE_FORMAT` line into a :class:`_PaneState`.""" - # ``maxsplit`` is one below the field count so a pane_pid or a - # future field containing ``|`` cannot shift the parse. Older tmux - # builds that do not know ``alternate_on`` emit the literal format - # text rather than a value, so treat anything but ``"1"`` as off - # instead of raising — this read is on the hot poll path and must - # degrade, not fail, across the CI tmux version matrix. - parts = raw.split("|", 5) - hs, cy, sy, pid, dead = parts[:5] - alternate = parts[5] if len(parts) > 5 else "0" + # ``maxsplit`` is one below the field count so a field containing + # ``|`` cannot shift the parse. Older tmux builds emit the literal + # format text for an unknown ``alternate_on``, so anything but ``"1"`` + # reads as off -- this is the hot poll path and must degrade. + parts = raw.split("|", 7) + hs, cy, sy, sx, in_mode, pid, dead = parts[:7] + alternate = parts[7] if len(parts) > 7 else "0" + # A pane that no longer exists expands EVERY field to empty, + # ``pane_dead`` included, so it reads as "0" and cannot report its own + # death. A live pane always has a pid, so an empty one is the gone + # signal; without it the pid mismatch below calls a kill a respawn. return _PaneState( - history_size=int(hs), - cursor_y=int(cy), - pane_height=int(sy), + history_size=_int_or_zero(hs), + cursor_y=_int_or_zero(cy), + pane_height=_int_or_zero(sy), + pane_width=_int_or_zero(sx), + in_mode=in_mode == "1", pane_pid=pid, - pane_dead=dead == "1", + pane_dead=dead == "1" or not pid, alternate_on=alternate == "1", ) @@ -130,4 +151,4 @@ def _read_history_limit(pane: Pane) -> int: """ stdout = pane.display_message(HISTORY_LIMIT_FORMAT, get_text=True) raw = stdout[0] if stdout else "0" - return int(raw) + return _int_or_zero(raw) diff --git a/src/libtmux_mcp/_patterns.py b/src/libtmux_mcp/_patterns.py new file mode 100644 index 00000000..8842041e --- /dev/null +++ b/src/libtmux_mcp/_patterns.py @@ -0,0 +1,245 @@ +r"""Caller-supplied regex compilation with a backtracking bound. + +``search_panes`` and ``wait_for_text`` both take a regex from the +caller and run it over pane content. Python's ``re`` backtracks, has no +step limit, and cannot be interrupted from another thread -- so a +pattern like ``(a+)+$`` against one 121-character line does not finish +in three minutes, and nothing downstream can stop it. + +That breaks two different promises. ``search_panes`` is readonly-tier +and sixteen concurrent calls exhaust the worker pool, after which every +tool is unresponsive. ``wait_for_text`` documents a ``timeout``, checks +it between poll iterations, and a match that never returns never +reaches the check -- so the caller who defends themselves with a small +timeout is exactly the one it does not protect. + +The bound has to be the pattern, because neither a deadline nor a +worker cap can reclaim a thread stuck inside ``re``. A repeat that can +iterate freely is refused when its body can match one string more than +one way -- a body of varying width (``(a+)+``, ``(a{0,3})*``, +``(a?){20}``), or alternatives that can begin on the same character +(``(a|a)+``). ``(cat|dog)+`` and ``(\d{2}){3}`` are fixed-width and +stay. + +That is a MODEL of catastrophic backtracking, not a proof of its +absence, and it is deliberately coarser than the engine: ``(a?)*`` is +refused although CPython prunes it, because a screen that leaned on +that pruning would also have to know when it does not apply. +""" + +from __future__ import annotations + +import re +import typing as t + +from libtmux_mcp._errors import ExpectedToolError + +if t.TYPE_CHECKING: # pragma: no cover - typing only + from collections.abc import Iterable + +try: # Python 3.11+ + import re._constants as _re_constants # type: ignore[import-not-found] + import re._parser as _re_parser # type: ignore[import-not-found] +except ImportError: # pragma: no cover - Python 3.10 + import sre_constants as _re_constants + import sre_parse as _re_parser + +_MAXREPEAT = _re_constants.MAXREPEAT +_REPEAT_OPS = frozenset( + getattr(_re_constants, name) + for name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT") + if hasattr(_re_constants, name) +) +_BRANCH = _re_constants.BRANCH +_SUBPATTERN = _re_constants.SUBPATTERN +_ATOMIC_GROUP = getattr(_re_constants, "ATOMIC_GROUP", None) +_ASSERT = _re_constants.ASSERT +_ASSERT_NOT = _re_constants.ASSERT_NOT +_GROUPREF_EXISTS = _re_constants.GROUPREF_EXISTS +_LITERAL = _re_constants.LITERAL + +#: Ops that match without consuming input, so they contribute no first +#: character of their own. +_ZERO_WIDTH = frozenset({_re_constants.AT, _ASSERT, _ASSERT_NOT}) + +#: A repeat this large is treated as unbounded for nesting purposes. +#: ``(\d{2}){3}`` is six characters of work; ``(a{1,50}){1,50}`` is a +#: bomb with a number in front of it. +_LARGE_REPEAT = 20 + +#: Ops that consume exactly one character. Everything else either +#: consumes nothing, nests, or is unmodelled -- and an unmodelled op is +#: given an unknown width, which only ever refuses more. +_ONE_CHARACTER = frozenset( + { + _LITERAL, + _re_constants.NOT_LITERAL, + _re_constants.IN, + _re_constants.ANY, + } +) + + +def _repeat_is_large(minimum: int, maximum: int) -> bool: + """Return True when a repeat can iterate enough times to matter.""" + return maximum is _MAXREPEAT or maximum >= _LARGE_REPEAT or maximum > minimum > 0 + + +def _subpatterns(op: t.Any, av: t.Any) -> list[Iterable[t.Any]]: + """Every pattern sequence nested inside one parsed node. + + Each walker below recurses through this, so a container op is taught + to the screen once instead of once per walker. An op missing here is + a repeat the screen cannot see. + """ + if op is _SUBPATTERN: + return [av[3]] + if op is _BRANCH: + return list(av[1]) + if op in _REPEAT_OPS: + return [av[2]] + if op is _ATOMIC_GROUP: + return [av] + if op is _ASSERT or op is _ASSERT_NOT: + return [av[1]] + if op is _GROUPREF_EXISTS: + return [branch for branch in av[1:] if branch] + return [] + + +def _width_range(node: Iterable[t.Any]) -> tuple[int, int | None]: + """How many characters *node* can match, as ``(minimum, maximum)``. + + ``None`` as the maximum means unbounded. A node whose two ends + differ can match one string in more than one way, and that is what + an enclosing repeat has to backtrack through. + """ + low, high = 0, t.cast("int | None", 0) + for op, av in node: + if op in _ZERO_WIDTH: + continue + if op in _ONE_CHARACTER: + lo, hi = 1, t.cast("int | None", 1) + elif op in _REPEAT_OPS: + body_lo, body_hi = _width_range(av[2]) + lo = av[0] * body_lo + hi = None if body_hi is None or av[1] is _MAXREPEAT else av[1] * body_hi + else: + children = _subpatterns(op, av) + if not children: + # A backreference, or an op this screen does not model. + lo, hi = 0, None + else: + spans = [_width_range(child) for child in children] + lo = min(span[0] for span in spans) + hi = ( + None + if any(span[1] is None for span in spans) + else max(t.cast("int", span[1]) for span in spans) + ) + low += lo + high = None if high is None or hi is None else high + hi + return low, high + + +def _is_variable_width(node: Iterable[t.Any]) -> bool: + """Whether *node* can match differing numbers of characters.""" + low, high = _width_range(node) + return high is None or high != low + + +def _first_characters(branch: Iterable[t.Any]) -> set[t.Any] | None: + """Characters a branch can start with, or ``None`` if unbounded. + + ``None`` means "assume it overlaps" and covers an EMPTY branch, + which is the worst case: a repeat whose body matches nothing is + ambiguous at every position. Any op this screen does not model + lands there too. + """ + for op, av in branch: + if op is _LITERAL: + return {av} + if op is _SUBPATTERN: + return _first_characters(av[3]) + if op is _BRANCH: + nested = [_first_characters(alt) for alt in av[1]] + if any(item is None for item in nested): + return None + return set().union(*(item for item in nested if item is not None)) + if op in _ZERO_WIDTH: + continue + return None + return None + + +def _branch_is_ambiguous(node: Iterable[t.Any]) -> bool: + """Return True when two alternatives can start on the same character.""" + for op, av in node: + if op is _BRANCH: + firsts = [_first_characters(alt) for alt in av[1]] + if any(item is None for item in firsts): + return True + known = [item for item in firsts if item is not None] + for i, left in enumerate(known): + if any(left & right for right in known[i + 1 :]): + return True + if any(_branch_is_ambiguous(child) for child in _subpatterns(op, av)): + return True + return False + + +def _ambiguous_repeat(node: Iterable[t.Any]) -> str | None: + """Return why *node* can backtrack catastrophically, or ``None``. + + Two shapes, both meaning "the body can match the same input more + than one way, so failing forces the engine to try every split": + + * a body of varying width -- ``(a+)+``, ``(a{0,3})*``, ``(a?){20}``. + A nested repeat is the common case, but the predicate is the + width, which also catches ``(a?a?){1,20}``. + * alternatives that can begin with the same character -- + ``(a|a)+``. ``(cat|dog)+`` is fine, since no input takes both. + """ + for op, av in node: + if op in _REPEAT_OPS and _repeat_is_large(av[0], av[1]): + if _is_variable_width(av[2]): + return "a repeated group whose body can match a varying width" + if _branch_is_ambiguous(av[2]): + return "a repeated group whose alternatives overlap" + for child in _subpatterns(op, av): + found = _ambiguous_repeat(child) + if found is not None: + return found + return None + + +def compile_pattern( + value: str, *, regex: bool, flags: int, label: str +) -> re.Pattern[str]: + """Compile a caller pattern, refusing one that could not be stopped. + + A literal (``regex=False``) is escaped and never checked -- it has + no quantifiers to nest. + """ + if not regex: + return re.compile(re.escape(value), flags) + try: + compiled = re.compile(value, flags) + except re.error as err: + msg = f"Invalid regex pattern: {err}" + raise ExpectedToolError(msg) from err + try: + parsed = _re_parser.parse(value, flags) + except re.error: # pragma: no cover - re.compile already accepted it + return compiled + reason = _ambiguous_repeat(parsed) + if reason is not None: + msg = ( + f"{label} pattern {value!r} contains {reason}, which can take " + "exponential time on a non-matching line. Python's regex engine " + "cannot be interrupted once it starts, so this is refused rather " + "than timed out. Rewrite the repeat without nesting -- 'a+' " + "rather than '(a+)+' -- or pass regex=false to match literally." + ) + raise ExpectedToolError(msg) + return compiled diff --git a/src/libtmux_mcp/_progress.py b/src/libtmux_mcp/_progress.py new file mode 100644 index 00000000..214f68ba --- /dev/null +++ b/src/libtmux_mcp/_progress.py @@ -0,0 +1,147 @@ +"""Progress reporting for the wait tools. + +``run_command`` and ``wait_for_channel`` await a single tmux child and +hear nothing until it returns, so without a ticker beside them a client +watching a thirty-second call saw the same thing whether the command +was running or the server had stopped answering. + +``wait_for_text`` has a poll loop and could report from inside it, but +does not, and that is deliberate. Reporting per iteration ties the +notification rate to ``interval`` -- a polling knob with a 0.01 floor +-- so the default emitted about twenty notifications a second and the +floor about a hundred, each an awaited JSON-RPC message carrying the +same sentence with a different decimal. The message only changes +meaningfully once a second. All three use this, at one cadence. + +The shape is the delicate part. The ticker must not outlive the wait, +must not swallow the cancellation the wait path is careful to +propagate, and must not turn a disconnected client into a failed tool +call. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +import typing as t + +import anyio + +if t.TYPE_CHECKING: # pragma: no cover - typing only + from collections.abc import AsyncIterator + + from fastmcp import Context + +#: Seconds between reports. Fine enough that a human sees the number +#: move, coarse enough that a long wait does not flood the transport. +_TICK_SECONDS = 1.0 + + +@contextlib.asynccontextmanager +async def progress_ticker( + ctx: Context | None, + *, + total: float, + message: t.Callable[[float, float], str], +) -> AsyncIterator[None]: + """Report elapsed/remaining beside a wait that cannot report itself. + + ``message`` receives ``(elapsed, remaining)`` so callers phrase the + line themselves; clients that surface progress usually show the + message rather than the raw pair. + + A ``None`` context starts nothing at all, which is what tests and + direct Python callers get. + """ + if ctx is None: + yield + return + + started = time.monotonic() + + async def _tick() -> None: + while True: + await asyncio.sleep(_TICK_SECONDS) + elapsed = time.monotonic() - started + await _maybe_report_progress( + ctx, + progress=elapsed, + total=total, + message=message(elapsed, max(total - elapsed, 0.0)), + ) + + task = asyncio.create_task(_tick()) + try: + yield + finally: + task.cancel() + # Await the cancelled ticker or it is left pending at teardown; + # ``suppress`` keeps its CancelledError from replacing the + # caller's. + with contextlib.suppress(asyncio.CancelledError): + await task + + +#: ``ClosedResourceError`` is the send side closing (our shutdown), +#: ``BrokenResourceError`` the receive side (peer disconnect), +#: ``BrokenPipeError`` stdio, ``ConnectionError`` socket families. +#: Anything else propagates so the caller sees it. +_TRANSPORT_CLOSED_EXCEPTIONS: tuple[type[BaseException], ...] = ( + anyio.ClosedResourceError, + anyio.BrokenResourceError, + BrokenPipeError, + ConnectionError, +) + + +async def _maybe_report_progress( + ctx: Context | None, + *, + progress: float, + total: float | None, + message: str, +) -> None: + """Call ``ctx.report_progress`` if a Context is available. + + Tests call the wait tools with ``ctx=None`` so progress plumbing is + optional. Only transport-closed exceptions are suppressed — a + progress report that fails because the client has disconnected is + unsurprising and must not take down the tool call. Everything else + (programming errors, kwarg mismatches, FastMCP internal failures) + propagates so it shows up in logs and tests instead of being + silently swallowed. + """ + if ctx is None: + return + try: + await ctx.report_progress(progress=progress, total=total, message=message) + except _TRANSPORT_CLOSED_EXCEPTIONS: + # Client gone; the poll loop will either complete or hit its + # timeout and return normally. No progress notification leaks. + return + + +_LogLevel = t.Literal["debug", "info", "warning", "error"] + + +async def _maybe_log( + ctx: Context | None, + *, + level: _LogLevel, + message: str, +) -> None: + """Call the matching ``ctx.{level}`` if a Context is available. + + Sibling to :func:`_maybe_report_progress` for client-visible log + notifications (``notifications/message`` in MCP). Same suppression + contract: silent only when the transport is gone, propagating + everything else so programming errors stay loud. + """ + if ctx is None: + return + method = getattr(ctx, level) + try: + await method(message) + except _TRANSPORT_CLOSED_EXCEPTIONS: + return diff --git a/src/libtmux_mcp/_resolve.py b/src/libtmux_mcp/_resolve.py new file mode 100644 index 00000000..cc7ea176 --- /dev/null +++ b/src/libtmux_mcp/_resolve.py @@ -0,0 +1,248 @@ +"""Turning a caller's id or name into a live libtmux object. + +Resolution is by explicit id first, then by name, then by the oldest +candidate, so an untargeted call is deterministic rather than arbitrary. +""" + +from __future__ import annotations + +import logging +import typing as t + +from libtmux import exc +from libtmux.server import Server + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.session import Session + from libtmux.window import Window + + +from libtmux_mcp._servers import _raise_if_server_unreachable + +logger = logging.getLogger(__name__) + + +def tmux_id_sort_key(raw: str | None) -> tuple[int, str]: + """Sort key placing tmux ids in creation order. + + ``$10`` is newer than ``$9``; a string sort says otherwise, and only + once ids pass nine -- on a long-lived server, which is exactly where + it would go unnoticed longest. + """ + text = raw or "" + digits = text[1:] if text[:1] in "$@%" else text + return (int(digits), text) if digits.isdigit() else (2**62, text) + + +def _oldest(objects: list[t.Any], id_field: str) -> t.Any: + """Return the object with the lowest tmux id, oldest surviving first. + + The untargeted default has to key on something a later call cannot + move. It used to be list order, and tmux lists sessions BY NAME -- + so ``rename_session`` silently redirected every later untargeted + call into a DIFFERENT session's pane, and nothing about that session + had changed. tmux's own rule for an omitted ``-t`` is no better: it + picks by ``activity_time``, which moves whenever any pane produces + output. + + tmux ids never move. They are sorted NUMERICALLY, not + lexicographically: after ``$0``..``$8`` are killed, a string sort + calls ``$10`` the lowest of ``$9``, ``$10``, ``$11`` -- wrong, and + only past nine, which is exactly where it would go unnoticed + longest. + """ + return min(objects, key=lambda obj: tmux_id_sort_key(getattr(obj, id_field, None))) + + +def _resolve_session( + server: Server, + session_name: str | None = None, + session_id: str | None = None, +) -> Session: + """Resolve a session by name or ID. + + Parameters + ---------- + server : Server + The tmux server. + session_name : str, optional + Session name to look up. + session_id : str, optional + Session ID (e.g. '$1') to look up. + + Returns + ------- + Session + + Raises + ------ + exc.TmuxObjectDoesNotExist + If no matching session is found. + """ + if session_id is not None: + session = server.sessions.get(session_id=session_id, default=None) + if session is None: + _raise_if_server_unreachable(server) + raise exc.TmuxObjectDoesNotExist( + obj_key="session_id", + obj_id=session_id, + list_cmd="list-sessions", + list_extra_args=(), + ) + return session + + if session_name is not None: + session = server.sessions.get(session_name=session_name, default=None) + if session is None: + _raise_if_server_unreachable(server) + raise exc.TmuxObjectDoesNotExist( + obj_key="session_name", + obj_id=session_name, + list_cmd="list-sessions", + list_extra_args=(), + ) + return session + + sessions = server.sessions + if not sessions: + _raise_if_server_unreachable(server) + raise exc.TmuxObjectDoesNotExist( + obj_key="session", + obj_id="(any)", + list_cmd="list-sessions", + list_extra_args=(), + ) + return t.cast("Session", _oldest(list(sessions), "session_id")) + + +def _resolve_window( + server: Server, + session: Session | None = None, + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, +) -> Window: + """Resolve a window by ID, index, or default. + + Parameters + ---------- + server : Server + The tmux server. + session : Session, optional + Session to search within. + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Session name for resolution. + session_id : str, optional + Session ID for resolution. + + Returns + ------- + Window + + Raises + ------ + exc.TmuxObjectDoesNotExist + If no matching window is found. + """ + if window_id is not None: + window = server.windows.get(window_id=window_id, default=None) + if window is None: + raise exc.TmuxObjectDoesNotExist( + obj_key="window_id", + obj_id=window_id, + list_cmd="list-windows", + list_extra_args=(), + ) + return window + + if session is None: + session = _resolve_session( + server, + session_name=session_name, + session_id=session_id, + ) + + if window_index is not None: + window = session.windows.get(window_index=window_index, default=None) + if window is None: + raise exc.TmuxObjectDoesNotExist( + obj_key="window_index", + obj_id=window_index, + list_cmd="list-windows", + list_extra_args=(), + ) + return window + + windows = session.windows + if not windows: + raise exc.NoWindowsExist() + return t.cast("Window", _oldest(list(windows), "window_id")) + + +def _resolve_pane( + server: Server, + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + window_index: str | None = None, + pane_index: str | None = None, +) -> Pane: + """Resolve a pane by ID or hierarchical targeting. + + Parameters + ---------- + server : Server + The tmux server. + pane_id : str, optional + Pane ID (e.g. '%1'). Globally unique within a server. + session_name : str, optional + Session name for hierarchical resolution. + session_id : str, optional + Session ID for hierarchical resolution. + window_id : str, optional + Window ID for hierarchical resolution. + window_index : str, optional + Window index for hierarchical resolution. + pane_index : str, optional + Pane index within the window. + + Returns + ------- + Pane + + Raises + ------ + exc.TmuxObjectDoesNotExist + If no matching pane is found. + """ + if pane_id is not None: + pane = server.panes.get(pane_id=pane_id, default=None) + if pane is None: + raise exc.PaneNotFound(pane_id=pane_id) + return pane + + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + + if pane_index is not None: + pane = window.panes.get(pane_index=pane_index, default=None) + if pane is None: + raise exc.PaneNotFound(pane_id=f"index:{pane_index}") + return pane + + panes = window.panes + if not panes: + raise exc.PaneNotFound() + return t.cast("Pane", _oldest(list(panes), "pane_id")) diff --git a/src/libtmux_mcp/_safety.py b/src/libtmux_mcp/_safety.py new file mode 100644 index 00000000..8c171a6d --- /dev/null +++ b/src/libtmux_mcp/_safety.py @@ -0,0 +1,109 @@ +"""Safety tiers, and the MCP annotations that publish them. + +Every tool carries exactly one tier tag. `SafetyMiddleware` gates on the +tag, so a tool is governed by construction rather than by a name list. +""" + +from __future__ import annotations + +import logging +import typing as t + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Safety tier tags +# --------------------------------------------------------------------------- + +TAG_READONLY = "readonly" +TAG_MUTATING = "mutating" +TAG_DESTRUCTIVE = "destructive" + +VALID_SAFETY_LEVELS = frozenset({TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE}) + +#: Non-tier marker for tools that enforce their own wall-clock ceiling, +#: whose cost is therefore *duration* rather than side effects. Such a +#: tool must never be re-driven by machinery that assumes a cheap call: +#: +#: * :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` skips it -- +#: the deadline lives in the tool body, so a retry doubles the ceiling. +#: * The ``call_*_tools_batch`` wrappers reject it per-operation: the +#: batch loop is serial with no aggregate deadline and +#: ``MAX_BATCH_OPERATIONS`` is 1000. +#: +#: A tag rather than a name list because ``add_tool_transformation`` can +#: rename a tool out from under a name. Tier resolution reads only the +#: three tier tags, so this one is inert elsewhere. +TAG_SELF_BOUNDED = "self-bounded" + +# --------------------------------------------------------------------------- +# Reusable annotation presets for tool registration +# --------------------------------------------------------------------------- + +ANNOTATIONS_RO: dict[str, bool] = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} +ANNOTATIONS_MUTATING: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} +ANNOTATIONS_CREATE: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, +} +#: Annotations for tools that move a user-supplied payload into a shell +#: context, whether directly (``send_keys``, ``run_command``, +#: ``paste_text``, ``pipe_pane``) or through a staged buffer +#: (``load_buffer`` then ``paste_buffer``). +#: +#: ``openWorldHint=True`` is what separates these from +#: :data:`ANNOTATIONS_CREATE`: the effect extends into whatever command +#: or content the caller supplies. +ANNOTATIONS_SHELL: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": True, +} +ANNOTATIONS_DESTRUCTIVE: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, +} + +#: Per-tool MCP ``meta`` hinting that a client keep this tool visible +#: rather than deferred. FastMCP passes ``meta`` opaquely and honouring it +#: is the client's business, so this is a safe no-op for one that does not +#: index the ``anthropic/*`` namespace. ``alwaysLoad`` is documented at +#: https://code.claude.com/docs/en/mcp, honoured from Claude Code 2.1.121. +#: +#: Apply only to read-tier discovery anchors -- ``list_panes``, +#: ``list_windows``, ``snapshot_pane`` -- because each always-loaded tool +#: spends a fixed schema budget in clients that do honour the hint. +DISCOVERY_META: dict[str, t.Any] = { + "anthropic/alwaysLoad": True, +} +#: Annotations for tools that stay in the ``mutating`` tier -- so they +#: remain visible to default-profile agents -- but can still terminate a +#: process or lose state. ``respawn_pane`` and ``clear_pane`` are the +#: canonical users: shell recovery and scrollback cleanup are ordinary +#: agent work, while the hints keep disclosing the cost. +#: +#: Hint values match :data:`ANNOTATIONS_DESTRUCTIVE`, which is paired with +#: ``TAG_DESTRUCTIVE`` where this one is paired with ``TAG_MUTATING``. Two +#: names for identical hints, so the call site states which it means. +ANNOTATIONS_MUTATING_DESTRUCTIVE: dict[str, bool] = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, +} diff --git a/src/libtmux_mcp/_serialize.py b/src/libtmux_mcp/_serialize.py new file mode 100644 index 00000000..3185bb27 --- /dev/null +++ b/src/libtmux_mcp/_serialize.py @@ -0,0 +1,156 @@ +"""libtmux objects to the pydantic models tools return. + +Each record is built from one tmux round trip, so its fields describe a +single moment rather than a sequence of them. +""" + +from __future__ import annotations + +import logging +import typing as t + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.session import Session + from libtmux.window import Window + + from libtmux_mcp.models import PaneInfo, SessionInfo, WindowInfo + +from libtmux_mcp._caller import _compute_is_caller + +logger = logging.getLogger(__name__) + + +def _serialize_session(session: Session) -> SessionInfo: + """Serialize a Session to a Pydantic model. + + Parameters + ---------- + session : Session + The session to serialize. + + Returns + ------- + SessionInfo + Session data including id, name, window count. + """ + from libtmux_mcp.models import SessionInfo + + assert session.session_id is not None + # ``getattr`` so a build without ``Session.active_pane``, or a session + # mid-teardown with none, reads as ``None`` and ``list_sessions`` still + # serialises. + active_pane = getattr(session, "active_pane", None) + active_pane_id = active_pane.pane_id if active_pane is not None else None + + return SessionInfo( + session_id=session.session_id, + session_name=session.session_name, + window_count=len(session.windows), + session_attached=getattr(session, "session_attached", None), + session_created=getattr(session, "session_created", None), + active_pane_id=active_pane_id, + ) + + +def _serialize_window(window: Window) -> WindowInfo: + """Serialize a Window to a Pydantic model. + + Parameters + ---------- + window : Window + The window to serialize. + + Returns + ------- + WindowInfo + Window data including id, name, index, pane count, layout. + """ + from libtmux_mcp.models import WindowInfo + + assert window.window_id is not None + active_pane = getattr(window, "active_pane", None) + active_pane_id = active_pane.pane_id if active_pane is not None else None + + return WindowInfo( + window_id=window.window_id, + window_name=window.window_name, + window_index=window.window_index, + session_id=window.session_id, + session_name=getattr(window, "session_name", None), + pane_count=len(window.panes), + window_layout=getattr(window, "window_layout", None), + window_active=getattr(window, "window_active", None), + window_width=getattr(window, "window_width", None), + window_height=getattr(window, "window_height", None), + active_pane_id=active_pane_id, + ) + + +def _coerce_int(value: str | None) -> int | None: + """Parse a tmux format-string number into ``int`` or ``None``. + + tmux format variables come back as strings; an empty string means + "tmux returned nothing" (e.g. older tmux that doesn't know the var). + """ + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_bool(value: str | None) -> bool | None: + """Parse a tmux ``"1"``/``"0"`` flag into ``bool`` or ``None``. + + Mirrors libtmux's own ``Pane.at_top`` / ``at_bottom`` typing, which + folds ``"1"`` to True and everything else to False — except we keep + ``None`` distinct so callers can tell "tmux didn't tell us" from + "tmux said no". + """ + if value is None or value == "": + return None + return value == "1" + + +def _serialize_pane(pane: Pane) -> PaneInfo: + """Serialize a Pane to a Pydantic model. + + Parameters + ---------- + pane : Pane + The pane to serialize. + + Returns + ------- + PaneInfo + Pane data including id, dimensions, geometry, current command, title. + """ + from libtmux_mcp.models import PaneInfo + + assert pane.pane_id is not None + return PaneInfo( + pane_id=pane.pane_id, + pane_index=getattr(pane, "pane_index", None), + pane_width=getattr(pane, "pane_width", None), + pane_height=getattr(pane, "pane_height", None), + pane_left=_coerce_int(getattr(pane, "pane_left", None)), + pane_top=_coerce_int(getattr(pane, "pane_top", None)), + pane_right=_coerce_int(getattr(pane, "pane_right", None)), + pane_bottom=_coerce_int(getattr(pane, "pane_bottom", None)), + pane_at_left=_coerce_bool(getattr(pane, "pane_at_left", None)), + pane_at_right=_coerce_bool(getattr(pane, "pane_at_right", None)), + pane_at_top=_coerce_bool(getattr(pane, "pane_at_top", None)), + pane_at_bottom=_coerce_bool(getattr(pane, "pane_at_bottom", None)), + pane_tty=getattr(pane, "pane_tty", None), + pane_current_command=getattr(pane, "pane_current_command", None), + pane_current_path=getattr(pane, "pane_current_path", None), + pane_pid=getattr(pane, "pane_pid", None), + pane_title=getattr(pane, "pane_title", None), + pane_active=getattr(pane, "pane_active", None), + window_id=pane.window_id, + session_id=pane.session_id, + session_name=pane.session_name, + is_caller=_compute_is_caller(pane), + ) diff --git a/src/libtmux_mcp/_servers.py b/src/libtmux_mcp/_servers.py new file mode 100644 index 00000000..90e98ea4 --- /dev/null +++ b/src/libtmux_mcp/_servers.py @@ -0,0 +1,332 @@ +"""The process-wide server cache, and the liveness it is gated on. + +A handle is probed before it is handed out and re-probed on every +cache hit: nothing downstream of `Server` is bounded. Every read and +write of the cache runs under `_server_cache_lock`. +""" + +from __future__ import annotations + +import logging +import os +import threading +import typing as t + +from libtmux.server import Server + +from libtmux_mcp._errors import ExpectedToolError +from libtmux_mcp._exec import ( + _LIVENESS_TIMEOUT_SECONDS, + _install_bounded_tmux_cmd, + _run_tmux_sync, + _tmux_argv, +) +from libtmux_mcp._tmux_proc import _run_tmux_bounded as _run_tmux_async + +logger = logging.getLogger(__name__) + + +_server_cache: dict[tuple[str | None, str | None, str | None], Server] = {} +_server_cache_lock = threading.Lock() + + +def _server_cache_key( + socket_name: str | None, socket_path: str | None +) -> tuple[str | None, str | None, str | None]: + """Cache key with the environment fallbacks already applied.""" + if socket_name is None: + socket_name = os.environ.get("LIBTMUX_SOCKET") + if socket_path is None: + socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") + return (socket_name, socket_path, os.environ.get("LIBTMUX_TMUX_BIN")) + + +#: Distinguishable by identity, so callers can tell "did not answer" +#: from every other unreachable reason without matching on prose. +HUNG_SOCKET_REASON = ( + "the tmux server accepted the connection but did not answer within " + f"{_LIVENESS_TIMEOUT_SECONDS:g}s" +) + + +_install_bounded_tmux_cmd() + + +def _build_server(*, socket_name: str | None, socket_path: str | None) -> Server: + """Construct an unprobed handle, honouring the same env fallbacks.""" + name, path, tmux_bin = _server_cache_key(socket_name, socket_path) + kwargs: dict[str, t.Any] = {} + if name is not None: + kwargs["socket_name"] = name + if path is not None: + kwargs["socket_path"] = path + if tmux_bin is not None: + kwargs["tmux_bin"] = tmux_bin + return Server(**kwargs) + + +def _raise_socket_hung(server: Server) -> t.NoReturn: + """Report a socket that accepted a connection and then said nothing.""" + target = server.socket_path or server.socket_name or "" + msg = ( + f"tmux server at {target} accepted the connection but did not " + f"answer within {_LIVENESS_TIMEOUT_SECONDS:g}s. It is running and " + "wedged rather than absent, so its sessions are not lost -- but no " + "tmux command against it can complete until it is killed." + ) + raise ExpectedToolError(msg) + + +def _get_server( + socket_name: str | None = None, + socket_path: str | None = None, +) -> Server: + """Get or create a cached Server instance. + + Parameters + ---------- + socket_name : str, optional + tmux socket name (-L). Falls back to LIBTMUX_SOCKET env var. + socket_path : str, optional + tmux socket path (-S). Falls back to LIBTMUX_SOCKET_PATH env var. + + Returns + ------- + Server + A cached libtmux Server instance. + """ + cache_key = _server_cache_key(socket_name, socket_path) + with _server_cache_lock: + cached = _server_cache.get(cache_key) + + # ``is_alive()`` is a tmux subprocess round trip; holding the cache + # lock across it serialises every concurrent tool call in this + # process -- measured, a 16-way socket scan capped at 2x, not 8x. + if cached is not None: + alive, reason = _probe_liveness(cached) + _raise_if_socket_hung(cached, reason) + if alive: + return cached + with _server_cache_lock: + if _server_cache.get(cache_key) is cached: + del _server_cache[cache_key] + + server = _build_server(socket_name=socket_name, socket_path=socket_path) + + # Probed before it is handed out: nothing downstream is bounded, as + # ``server.panes`` and friends reach ``Server.cmd``, which has no + # timeout. One extra round trip on the uncached path; the cached path + # above already pays one on every call. + _, reason = _probe_liveness(server) + _raise_if_socket_hung(server, reason) + + # Two threads racing to fill the same key both build a valid handle; + # ``setdefault`` makes them agree on which one the cache keeps. + with _server_cache_lock: + return _server_cache.setdefault(cache_key, server) + + +def _raise_if_socket_hung(server: Server, reason: str | None) -> None: + """Refuse to hand out a server that accepted a connection in silence. + + A DEAD socket is not this: it answers immediately with "no server + running" and every tool reports it correctly. This is only the + socket that takes the connection and never replies, where the + alternative to refusing is blocking a worker until the process ends. + """ + if reason is not HUNG_SOCKET_REASON: + return + _raise_socket_hung(server) + + +async def _get_server_async( + socket_name: str | None = None, + socket_path: str | None = None, +) -> Server: + """Resolve a server without blocking the event loop. + + ``_get_server`` runs a tmux subprocess to check the socket answers, + which is ~4 ms against a healthy server and the full liveness bound + against one that never replies. Called directly from an async tool + that cost every OTHER in-flight call the same wait: measured, an + ``capture_since`` against a wedged socket held the loop for 5.01 s + and the ticker beside it advanced once. + + The blocking predates the bound -- the cached path always shelled + out -- but a bounded 5 s stall shared by every concurrent caller is + still a stall, and the async tools are the ones with company. + + An async SUBPROCESS, not ``to_thread``: the wait path forbids + worker threads outright, because + ``concurrent.futures.thread._python_exit`` joins them with no + timeout and one wedged tmux would hang interpreter exit forever. + A subprocess we own can be killed. See + :mod:`libtmux_mcp._tmux_proc`. + """ + server = _build_server(socket_name=socket_name, socket_path=socket_path) + cache_key = _server_cache_key(socket_name, socket_path) + with _server_cache_lock: + cached = _server_cache.get(cache_key) + probe = cached if cached is not None else server + returncode = 0 + try: + returncode, _stdout, _stderr = await _run_tmux_async( + _tmux_argv(probe, "list-sessions"), + timeout=_LIVENESS_TIMEOUT_SECONDS, + ) + except TimeoutError: + _raise_socket_hung(probe) + except OSError: + pass # a missing binary or socket is not a hang; the caller sees it + if cached is not None: + if returncode == 0: + return cached + # Matches the synchronous path: a handle whose server is gone is + # dropped rather than reused. Diverging here would mean the same + # socket answered differently depending on which tool asked. + with _server_cache_lock: + if _server_cache.get(cache_key) is cached: + del _server_cache[cache_key] + with _server_cache_lock: + return _server_cache.setdefault(cache_key, server) + + +def _invalidate_server( + socket_name: str | None = None, + socket_path: str | None = None, +) -> None: + """Evict a server from the cache. + + Parameters + ---------- + socket_name : str, optional + tmux socket name used in the cache key. + socket_path : str, optional + tmux socket path used in the cache key. + """ + if socket_name is None: + socket_name = os.environ.get("LIBTMUX_SOCKET") + if socket_path is None: + socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") + + with _server_cache_lock: + keys_to_remove = [ + key + for key in _server_cache + if key[0] == socket_name and key[1] == socket_path + ] + for key in keys_to_remove: + del _server_cache[key] + + +def _drain_server_cache() -> list[Server]: + """Empty the cache and return the servers it held. + + The caller works on a snapshot, so per-server teardown work never + runs with an iterator open on a dict another thread may fill. + """ + with _server_cache_lock: + servers = list(_server_cache.values()) + _server_cache.clear() + return servers + + +def _raise_if_server_unreachable(server: Server) -> None: + """Refuse to read an empty enumeration as an absence. + + ``server.sessions`` swallows a query failure and yields an empty + list, so a resolver turning "not in the list" into "does not exist" + asserts the object is GONE when the truth is that the server could + not be asked. Measured against a live 3.7c server queried by a 3.2a + client: ``rename_session`` reported the session missing while it was + running, which invites recreating it under the same name. + + Only the session resolver needed this. Resolvers keyed on + ``pane_id`` or ``window_id`` let tmux's own error through, which is + untidy but never false -- they are the ones already telling the + truth. + + Also covers the opposite end. ``_probe_liveness`` separates "no + server" from "unreachable", and a missing server reaching the + object-not-found path produced advice that cannot work: it tells the + caller to run ``list_sessions``, which fails identically. Both + branches raise here so neither answer is a guess. + """ + alive, reason = _probe_liveness(server) + if alive: + return + if reason is not None: + msg = ( + f"tmux server exists but could not be queried: {reason}. " + "Reporting the object as missing would be wrong rather than " + "merely unhelpful." + ) + raise ExpectedToolError(msg) + # No server at all is not "that object is missing": the + # object-not-found path advises list_sessions, which fails the same + # way here and sends the caller round the loop it is already in. + socket = getattr(server, "socket_name", None) or getattr( + server, "socket_path", None + ) + msg = f"no tmux server is running{f' on {socket}' if socket else ''}" + raise ExpectedToolError( + msg, + suggestion=( + "There is no enumeration to consult. create_session starts a " + "server and a session in one call; list_servers finds sockets " + "that already have one." + ), + ) + + +#: tmux stderr fragments meaning the socket has no daemon behind it. +#: Anything else on a failed ``list-sessions`` -- a protocol mismatch, a +#: permission error -- is a server that exists and cannot be reached, +#: which is a different answer. +_NO_SERVER_MARKERS = ( + "no server running", + "no such file or directory", + "error connecting to", +) + + +def _probe_liveness(server: Server) -> tuple[bool, str | None]: + """Return ``(alive, unreachable_reason)`` for *server*. + + ``Server.is_alive()`` answers False for a socket with no daemon AND + for a live server this tmux binary cannot speak to, and + ``Server.sessions`` degrades to ``[]`` in both cases. libtmux's own + docstring points at ``is_alive`` to tell those apart, but it cannot: + both collapse to the same False. + + The difference matters because they warrant opposite reactions. "No + server" is a fact an agent can act on; "cannot reach the server" over + a socket whose daemon is running -- an ordinary tmux upgrade leaves + sockets older than the binary -- reported as False tells the agent + the user's work is gone. tmux distinguishes them on stderr, so read + it rather than the boolean. + + There is a THIRD case stderr cannot report, because nothing is + written: a server spinning inside its own event loop accepts the + connection and never replies. ``Server.cmd`` has no timeout, so the + probe meant to classify the server hung on it instead. Bounded here, + and a timeout is reported as unreachable -- which is what it is. + """ + try: + result = _run_tmux_sync( + server, "list-sessions", timeout=_LIVENESS_TIMEOUT_SECONDS + ) + except Exception as err: # noqa: BLE001 - probe must not raise + return False, str(err) + + if result is None: + return False, HUNG_SOCKET_REASON + + if result.returncode == 0: + return True, None + + detail = result.stderr.strip() + lowered = detail.lower() + if any(marker in lowered for marker in _NO_SERVER_MARKERS): + return False, None + return False, detail or f"tmux exited with status {result.returncode}" diff --git a/src/libtmux_mcp/_tmux_format.py b/src/libtmux_mcp/_tmux_format.py new file mode 100644 index 00000000..327a1fb1 --- /dev/null +++ b/src/libtmux_mcp/_tmux_format.py @@ -0,0 +1,170 @@ +"""Escaping for arguments tmux runs through its format expander. + +Several tmux commands expand ``#{...}`` in an argument that the caller +meant literally, and they do it unconditionally -- there is no ``-F`` to +turn it off. A tool that promises "this is the name I will store" has to +escape, or it stores something else: + + rename_window(new_name="#{pane_current_path}") + -> window named "/home/user/src/project" + +That is a disclosure vector, not just a wrong string: ``#{host}``, +``#{pane_pid}`` and friends all interpolate server state into a name +that shows up in the terminal and in ``list_panes``. + +**Doubling every ``#`` is the obvious escape and it is wrong.** A +``#``-run followed by ``[`` is a style sequence reserved for +``format_draw``; the expander copies the whole run through verbatim and +never collapses it, so doubling corrupts the value it was meant to +protect. The unit that needs escaping is the *run*, not the ``#``. +Measured against tmux 3.7b / e802909d: + +================== ================== ========================== +input expands to note +================== ================== ========================== +``#{pane_id}`` ``%0`` substituted +``##{pane_id}`` ``#{pane_id}`` run doubling escapes it +``####{a}`` ``##{a}`` composes for longer runs +``#S`` *(session name)* legacy single-char alias +``#(echo hi)`` *(command job)* substituted away +``#[fg=red]`` ``#[fg=red]`` verbatim +``##[fg=red]`` ``##[fg=red]`` verbatim -- never collapses +``issue ##42`` ``issue #42`` ordinary run collapses +================== ================== ========================== + +**There are two expanders, and they disagree about ``%``.** Which one +runs is a property of the tmux command, so each call site has to say +which it is talking to: + +=========================== ==================== ============== +tmux entry point applies ``%`` +=========================== ==================== ============== +``format_expand()`` ``#``-formats literal +``format_expand_time()`` ``#``-formats + also strftime + ``strftime`` +=========================== ==================== ============== + +Only ``pipe-pane`` reaches the time-expanding one, via +``format_expand_time()`` in ``cmd-pipe-pane.c``. Everything else lands +on ``format_single`` -> ``format_expand``, where ``%`` is an ordinary +character -- ``select-pane -T '%Y-%m-%d'`` stores ``%Y-%m-%d``, so +doubling ``%`` there would corrupt it just as surely as doubling ``#`` +before ``[``. +""" + +from __future__ import annotations + +import re + +#: A maximal run of ``#`` immediately before ``(``. The run length +#: decides whether a job starts, so the run is what gets measured. +_TMUX_HASH_RUN_BEFORE_PAREN = re.compile(r"(#+)\(") + +#: A maximal run of ``#``, plus the ``[`` that may follow it. tmux +#: treats a ``#``-run by what comes next, so the run is the unit that +#: has to be escaped -- not the individual ``#``. +_TMUX_HASH_RUN = re.compile(r"(#+)(\[?)") + + +def _escape_run(match: re.Match[str]) -> str: + run, bracket = match.group(1), match.group(2) + if bracket: + return f"{run}{bracket}" + return run * 2 + + +def escape_format(value: str) -> str: + """Escape ``value`` for a tmux argument expanded by ``format_expand``. + + Use for every command whose argument reaches ``format_single`` -- + ``rename-window``, ``rename-session``, ``select-pane -T``, + ``new-window -n``, ``new-session -s/-n/-c``, ``set-option`` and + ``show-options`` (the option *name*), and ``load-buffer`` (the + *path*). Round-trips exactly: ``show_option(escape_format(x))`` + reads back ``x``. + + Parameters + ---------- + value : str + The literal the caller wants tmux to store or match. + + Returns + ------- + str + ``value`` with each ``#``-run doubled, except a run followed by + ``[``. ``%`` is left alone -- see the module docstring. + """ + return _TMUX_HASH_RUN.sub(_escape_run, value) + + +def escape_format_time(value: str) -> str: + """Escape ``value`` for an argument expanded by ``format_expand_time``. + + Only ``pipe-pane`` needs this. Adds strftime escaping on top of + :func:`escape_format`: an unescaped ``%`` is a strftime directive + there, so ``100%done.log`` became ``10025one.log`` (``%d`` -> day of + month) and ``date-%Y.log`` became ``date-2026.log``. + + Parameters + ---------- + value : str + The literal path the caller wants written. + + Returns + ------- + str + ``value`` with ``#``-runs escaped as in :func:`escape_format`, + and every ``%`` doubled. + """ + return escape_format(value).replace("%", "%%") + + +def contains_format_job(value: str) -> bool: + """Whether ``value`` would start a ``#(command)`` job. + + The parity of the ``#``-run decides it, for the same reason the + escaper doubles runs: ``format_expand1`` consumes ``#`` pairs into a + literal ``#`` before it ever looks for ``(``, so only an ODD run + leaves a bare ``#`` to open a job. Measured, and matching + ``format.c``'s ``case '#'`` (emit a literal and continue) against + its ``case '('`` (start a job): + + ========== ============== ============== + input renders as runs a job + ========== ============== ============== + ``#(x)`` *(job output)* yes + ``##(x)`` ``#(x)`` no + ``###(x)`` ``#`` + output yes + ``####(x)`` ``##(x)`` no + ========== ============== ============== + + A bare ``"#(" in value`` test blocks all four, so a caller could not + put a literal ``#(`` in a label or a code snippet even though tmux + would render it harmlessly. + + Parameters + ---------- + value : str + A caller-supplied tmux format string. + + Returns + ------- + bool + True when any ``#``-run before ``(`` is odd-length. + """ + return any( + len(match.group(1)) % 2 for match in _TMUX_HASH_RUN_BEFORE_PAREN.finditer(value) + ) + + +def _escaped_or_none(start_directory: str | None) -> str | None: + """Escape a spawn cwd, passing ``None`` through. + + Every spawn command expands its ``-c`` argument -- measured on + ``split-window``, ``new-window``, ``respawn-pane`` and + ``new-session``, all four. An unescaped ``#`` does not fail: tmux + expands the path into one that does not exist and silently starts + the shell in ``$HOME`` instead, so the caller is told the pane was + created and never learns it is in the wrong directory. + """ + return None if start_directory is None else escape_format(start_directory) diff --git a/src/libtmux_mcp/_tmux_proc.py b/src/libtmux_mcp/_tmux_proc.py index 9bf169a7..69d2d449 100644 --- a/src/libtmux_mcp/_tmux_proc.py +++ b/src/libtmux_mcp/_tmux_proc.py @@ -19,6 +19,26 @@ ``shutdown(wait=False)`` is joined by that same hook). A subprocess we own can simply be killed, so this module owns it. + +Every async tool now reaches tmux this way -- ``wait_for_text``, +``wait_for_channel``, ``capture_since`` and ``run_command``. The last +two were converted after a socket that forwards its FIRST connection +and stalls the rest showed them unable to return AND the process unable +to exit; a socket that never answers cannot show it, because the +bounded liveness probe catches that one before the unbounded call is +reached. The event loop keeps ticking throughout, so no loop-blocking +test can see this class either. + +``asyncio.to_thread`` remains correct for BOUNDED work -- see +``_run_send_keys``, whose every argv runs under a timeout, so its worker +always returns. The hazard is the untimed call, not the thread. + +``tests/test_pane_tools.py`` enforces this structurally: it reads the +tree for a tmux call made inline from an async body, and for a libtmux +method called on any receiver. Both halves of the failure -- the call +never returning, and the process then unable to exit -- were confirmed +by two independently built fixtures, each shown to fire when the defect +is present and to stay silent when it is not. """ from __future__ import annotations @@ -71,7 +91,7 @@ async def _run_tmux_bounded( ---------- argv : list of str Full tmux command vector, as built by - :func:`~libtmux_mcp._utils._tmux_argv`. + :func:`~libtmux_mcp._exec._tmux_argv`. timeout : float Wall-clock bound in seconds. On expiry the child is killed and reaped before ``TimeoutError`` is raised. @@ -94,13 +114,10 @@ async def _run_tmux_bounded( stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - # Deliberately NOT ``wait_for(proc.communicate())``. Measured: a - # wedged tmux that leaves a grandchild holding the stdout/stderr - # write ends never reaches EOF, and ``await proc.wait()`` after - # ``kill()`` then deadlocks — the pipes outlive the process we - # killed. ``asyncio.wait`` observes the timeout without cancelling, - # so the kill happens first and the read task is torn down after, - # when cancelling it can actually succeed. + # Not ``wait_for(proc.communicate())``: it cancels the reader on + # timeout, and a wedged tmux can leave a grandchild holding the pipe + # write ends, so that cancel hangs. ``asyncio.wait`` returns without + # cancelling, so the kill can go first. task = asyncio.ensure_future(proc.communicate()) try: done, _pending = await asyncio.wait({task}, timeout=timeout) @@ -109,12 +126,9 @@ async def _run_tmux_bounded( raise TimeoutError stdout, stderr = task.result() except asyncio.CancelledError: - # The whole call was cancelled (MCP client hung up). Tear the - # child down before letting the cancellation through, or tmux - # is orphaned. The cancellation lands on the ``asyncio.wait`` - # above just as often as on ``task.result()``, so the guard - # must span both — otherwise a cancel while waiting orphans the - # child. + # Reap before propagating, or tmux is orphaned. The guard spans + # both await points: a cancel lands on ``asyncio.wait`` as often + # as on ``task.result()``. await _kill_and_reap(proc, task) raise assert proc.returncode is not None diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py deleted file mode 100644 index 587e2d85..00000000 --- a/src/libtmux_mcp/_utils.py +++ /dev/null @@ -1,1139 +0,0 @@ -"""Shared utilities for libtmux MCP server. - -Provides server caching, object resolution, serialization, and error handling -for all MCP tool functions. -""" - -from __future__ import annotations - -import dataclasses -import functools -import json -import logging -import os -import pathlib -import threading -import typing as t - -from fastmcp.exceptions import ToolError -from libtmux import exc -from libtmux._internal.query_list import LOOKUP_NAME_MAP -from libtmux.server import Server - -if t.TYPE_CHECKING: - from libtmux.pane import Pane - from libtmux.session import Session - from libtmux.window import Window - - from libtmux_mcp.models import PaneInfo, SessionInfo, WindowInfo - -logger = logging.getLogger(__name__) - - -class ExpectedToolError(ToolError): - """``ToolError`` for expected, agent-correctable failures. - - Defaults the error's ``log_level`` to ``WARNING`` (honored by - fastmcp >= 3.3 when logging tool/resource failures) so routine - validation errors, missing objects, and tier denials do not surface - as ERROR records. Unexpected failures keep stock :class:`ToolError` - and its ERROR default — those are the ones operators must see. - - Parameters - ---------- - *args : object - Positional arguments forwarded to :class:`ToolError` - (typically the error message). - log_level : int - Level fastmcp's server layer logs this failure at. Defaults - to ``logging.WARNING``. - suggestion : str, optional - Agent-facing recovery hint. - :class:`~libtmux_mcp.middleware.ToolErrorResultMiddleware` - appends it to the error result's text and mirrors it into the - result's ``meta``. - - Examples - -------- - >>> import logging - >>> ExpectedToolError("Pane not found: %5").log_level == logging.WARNING - True - - An explicit level still wins: - - >>> err = ExpectedToolError("noisy", log_level=logging.INFO) - >>> err.log_level == logging.INFO - True - - Catch sites that handle ``ToolError`` keep working — this is a - plain subclass: - - >>> isinstance(ExpectedToolError("x"), ToolError) - True - - An optional ``suggestion`` carries an agent-facing recovery hint; - :class:`libtmux_mcp.middleware.ToolErrorResultMiddleware` surfaces - it in the error result's text and ``meta``: - - >>> err = ExpectedToolError("Pane not found: %5", - ... suggestion="Call list_panes to discover valid pane ids.") - >>> err.suggestion - 'Call list_panes to discover valid pane ids.' - >>> ExpectedToolError("no hint").suggestion is None - True - """ - - def __init__( - self, - *args: object, - log_level: int = logging.WARNING, - suggestion: str | None = None, - ) -> None: - super().__init__(*args, log_level=log_level) - self.suggestion = suggestion - - -@dataclasses.dataclass(frozen=True) -class CallerIdentity: - """Identity of the tmux pane hosting this MCP server process. - - Parsed from the ``TMUX`` and ``TMUX_PANE`` environment variables that - tmux injects into every child of a pane. ``TMUX`` has the format - ``socket_path,server_pid,session_id`` (see tmux ``environ.c:281``). - - Used to scope self-protection checks to the caller's own tmux server — - a pane ID like ``%1`` is only unique within a single server, so - comparisons must also verify the socket path matches. - - Attributes - ---------- - socket_path : str | None - Filesystem path of the tmux socket the caller is attached to, from - the first ``TMUX`` field. ``None`` when ``TMUX`` is unset or its - first field is empty — the socket comparisons in - :func:`_caller_is_on_server` and - :func:`_caller_is_strictly_on_server` then cannot prove which - server the caller belongs to. - server_pid : int | None - PID of the tmux server process, from the second ``TMUX`` field. - ``None`` when that field is absent or not an integer. - session_id : str | None - Session the caller's pane belongs to (e.g. ``$7``), from the third - ``TMUX`` field. ``None`` when that field is absent or empty. - pane_id : str | None - Pane the MCP server process runs in (e.g. ``%3``), read from - ``TMUX_PANE``. ``None`` when the variable is unset, meaning no - pane can be identified as the caller's own. - """ - - socket_path: str | None - server_pid: int | None - session_id: str | None - pane_id: str | None - - -def _get_caller_identity() -> CallerIdentity | None: - """Return the caller's tmux identity, or None if not inside tmux. - - Reads ``TMUX`` for socket_path/server_pid/session_id and ``TMUX_PANE`` - for the pane id. Tolerant of missing/malformed ``TMUX`` values — - callers should check individual fields rather than relying on all - being populated. - """ - pane_id = os.environ.get("TMUX_PANE") - tmux_env = os.environ.get("TMUX") - - if not tmux_env and not pane_id: - return None - - socket_path: str | None = None - server_pid: int | None = None - session_id: str | None = None - - if tmux_env: - parts = tmux_env.split(",", 2) - if parts: - socket_path = parts[0] or None - if len(parts) >= 2 and parts[1]: - try: - server_pid = int(parts[1]) - except ValueError: - server_pid = None - if len(parts) >= 3 and parts[2]: - session_id = parts[2] - - return CallerIdentity( - socket_path=socket_path, - server_pid=server_pid, - session_id=session_id, - pane_id=pane_id, - ) - - -def _compute_is_caller(pane: Pane) -> bool | None: - """Decide whether ``pane`` is the MCP caller's own tmux pane. - - The returned value is used as the ``is_caller`` annotation on - :class:`~libtmux_mcp.models.PaneInfo`, - :class:`~libtmux_mcp.models.PaneSnapshot`, and - :class:`~libtmux_mcp.models.PaneContentMatch`. - - Tri-state semantics match the original bare-equality check: - - * ``None`` — process is not inside tmux at all (neither ``TMUX`` nor - ``TMUX_PANE`` are set). No caller exists, so the annotation - carries no signal. - * ``True`` — the caller's ``TMUX_PANE`` matches ``pane.pane_id`` - *and* :func:`_caller_is_strictly_on_server` confirms the - caller's socket realpath equals the target's. - * ``False`` — the pane ids differ, or they match but the socket - does not (or cannot be proven to). A bare pane-id equality - check would have returned ``True`` here, which is the - cross-socket false-positive fixed by - tmux-python/libtmux-mcp#19. - - Uses :func:`_caller_is_strictly_on_server` rather than - :func:`_caller_is_on_server`: the kill-guard comparator is - conservative-True-when-uncertain (right for blocking destructive - actions, wrong for an informational annotation that should - demand a positive match). The strict variant declines the - basename fallback, the unresolvable-target branch, and the - socket-path-unset branch so ambiguous cases resolve to ``False``. - """ - caller = _get_caller_identity() - if caller is None or caller.pane_id is None: - return None - return caller.pane_id == pane.pane_id and _caller_is_strictly_on_server( - pane.server, caller - ) - - -def _effective_socket_path(server: Server) -> str | None: - """Return the filesystem socket path a Server will actually use. - - libtmux leaves :attr:`libtmux.Server.socket_path` as ``None`` when only - ``socket_name`` (or neither) was supplied, but tmux still resolves to - a real path under ``${TMUX_TMPDIR:-/tmp}/tmux-/``. This - helper reproduces that resolution so :func:`_caller_is_on_server` can - compare against the caller's ``TMUX`` socket path. - - Resolution order: - - 1. :attr:`libtmux.Server.socket_path` if libtmux already has it. - 2. ``tmux display-message -p '#{socket_path}'`` against the target - server — authoritative because tmux itself reports the path it - is actually using, regardless of our process environment. - Necessary on macOS where ``$TMUX_TMPDIR`` under launchd diverges - from the interactive shell (see ``docs/topics/safety.md`` for - the self-kill guard gap this closes). - 3. Fallback: reconstruct from ``$TMUX_TMPDIR`` + euid + socket name. - This path is reached only when the target server is unreachable - (e.g. not running), in which case no self-kill is possible and - the conservative caller check still blocks via - ``_caller_is_on_server``'s None-socket branch. - """ - if server.socket_path: - return str(server.socket_path) - # Preferred: ask tmux directly. ``display-message -p`` prints the - # value to stdout and exits, so this is cheap. Wrapped defensively - # because the server may be down, the format may be unsupported on - # ancient tmux, or permissions may deny the call. - try: - resolved = server.cmd( - "display-message", - "-p", - "#{socket_path}", - ).stdout - except (exc.LibTmuxException, OSError): - resolved = None - if resolved: - first = resolved[0].strip() - if first: - return first - tmux_tmpdir = os.environ.get("TMUX_TMPDIR", "/tmp") - socket_name = server.socket_name or "default" - return str(pathlib.Path(tmux_tmpdir) / f"tmux-{os.geteuid()}" / socket_name) - - -def _caller_is_on_server(server: Server, caller: CallerIdentity | None) -> bool: - """Return True if ``caller`` looks like it is on the same tmux server. - - Compares socket paths via :func:`os.path.realpath` so symlinked temp - dirs still match, then falls back to basename comparison when - realpath disagrees — the authoritative caller-side ``$TMUX`` name - and the target's declared ``socket_name`` are both unaffected by - ``$TMUX_TMPDIR`` divergence (the macOS launchd case), so a - last-chance name match still blocks a self-kill when the path - comparison was fooled by env mismatch. - - Decision table: - - * ``caller is None`` → ``False``. The process isn't inside tmux at - all, so there is no caller-side pane to protect and no self-kill - is possible. - * caller has a pane id but no socket path (e.g. ``TMUX_PANE`` set - without ``TMUX``) → ``True``. We can't rule out that the caller - is on the target server, so err on the side of blocking a - destructive action. - * target server has no resolvable socket path → ``True``. Same - conservative reasoning. - * realpath of caller's socket path matches target's effective path - → ``True`` (primary positive signal). - * basename of caller's socket path equals target's - ``socket_name`` (or ``"default"``) → ``True``. Conservative - last-chance block for env-mismatch scenarios where reconstruction - produced a wrong path but the name was authoritative on both - sides. Trades off one exotic false positive (two daemons with - identical socket_name under different tmpdirs) for a real safety - property. - * Otherwise → ``False``. - - When a conservative block is a false positive, the caller's error - message directs the user to run tmux manually. - """ - if caller is None: - return False - if not caller.socket_path: - return caller.pane_id is not None - target = _effective_socket_path(server) - if not target: - return True - try: - if os.path.realpath(caller.socket_path) == os.path.realpath(target): - return True - except OSError: - if caller.socket_path == target: - return True - # Final conservative check: names match even though paths didn't. - # Survives ``$TMUX_TMPDIR`` divergence between the MCP process and - # the caller's shell (macOS launchd). - caller_basename = pathlib.PurePath(caller.socket_path).name - target_name = server.socket_name or "default" - return caller_basename == target_name - - -def _caller_is_strictly_on_server( - server: Server, caller: CallerIdentity | None -) -> bool: - """Return True only on a confirmed socket-path match. - - Counterpart to :func:`_caller_is_on_server` for the informational - :attr:`~libtmux_mcp.models.PaneInfo.is_caller` annotation. The - destructive-action guard is biased toward True-when-uncertain so a - macOS ``$TMUX_TMPDIR`` divergence cannot fool it into permitting - self-kill; the annotation cannot absorb that bias — ambiguous cases - are exactly the cross-socket false positives documented by - tmux-python/libtmux-mcp#19. This function therefore declines every - branch other than a confirmed ``realpath`` match. - - Decision table: - - * ``caller is None`` → ``False``. No caller identity. - * ``caller.socket_path`` unset (``TMUX_PANE`` set without ``TMUX``) - → ``False``. We cannot verify the caller is on this server. - * target server's effective socket path unresolvable → ``False``. - * ``realpath`` of caller's socket path equals target's effective - path → ``True``. Primary and only positive signal. - * Fallback on ``OSError`` from ``realpath``: exact string match - → ``True``. Still a positive signal, just without the resolve - step. - * Otherwise → ``False`` (including the basename-only match that - :func:`_caller_is_on_server` permits as a conservative block). - """ - if caller is None or not caller.socket_path: - return False - target = _effective_socket_path(server) - if not target: - return False - try: - return os.path.realpath(caller.socket_path) == os.path.realpath(target) - except OSError: - return caller.socket_path == target - - -# --------------------------------------------------------------------------- -# Safety tier tags -# --------------------------------------------------------------------------- - -TAG_READONLY = "readonly" -TAG_MUTATING = "mutating" -TAG_DESTRUCTIVE = "destructive" - -VALID_SAFETY_LEVELS = frozenset({TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE}) - -#: Non-tier marker tag for tools that enforce their own wall-clock -#: ceiling internally and whose cost is therefore *duration*, not -#: side effects. -#: -#: A tagged tool must never be re-driven by machinery that assumes a -#: call is cheap: -#: -#: * :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` skips it, -#: because the deadline is computed inside the tool body — a retry -#: restarts the clock and doubles the ceiling. -#: * The ``call_*_tools_batch`` wrappers reject it per-operation, -#: because the batch loop is serial with no aggregate deadline and -#: ``MAX_BATCH_OPERATIONS`` is 1000. -#: -#: A TAG rather than a tool-name list on purpose: a name string is -#: exactly what ``add_tool_transformation`` can rename out from under -#: the exclusion. Tier resolution -#: (:meth:`~libtmux_mcp.middleware.SafetyMiddleware._is_allowed`, -#: ``batch_tools._tool_tier``) inspects only the three tier tags, so -#: carrying this extra tag is inert everywhere else. -TAG_SELF_BOUNDED = "self-bounded" - -# --------------------------------------------------------------------------- -# Reusable annotation presets for tool registration -# --------------------------------------------------------------------------- - -ANNOTATIONS_RO: dict[str, bool] = { - "readOnlyHint": True, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, -} -ANNOTATIONS_MUTATING: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": True, - "openWorldHint": False, -} -ANNOTATIONS_CREATE: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": False, -} -#: Annotations for tools that move user-supplied payloads into a shell -#: context. Six consumers today: -#: -#: * ``send_keys``, ``run_command``, ``paste_text``, ``pipe_pane`` — the -#: canonical shell-driving tools; caller's keys/command/text/stream -#: reaches the shell prompt or pipes into an external command -#: respectively. -#: * ``load_buffer``, ``paste_buffer`` — ``load_buffer`` stages content -#: into a tmux paste buffer; ``paste_buffer`` pushes that content -#: into a target pane where the shell receives it as input. The two -#: are split into a stage/fire pair so callers can validate before -#: paste, but both participate in the same open-world transfer. -#: -#: Distinguished from :data:`ANNOTATIONS_CREATE` by ``openWorldHint=True``: -#: the effects of these tools extend into whatever command or content -#: the caller supplies, which is the canonical open-world MCP -#: interaction. -ANNOTATIONS_SHELL: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": False, - "idempotentHint": False, - "openWorldHint": True, -} -ANNOTATIONS_DESTRUCTIVE: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": False, - "openWorldHint": False, -} - -#: Per-tool MCP ``meta`` payload that hints clients to keep this tool -#: always visible (not deferred). FastMCP passes ``meta`` opaquely -#: (verified vs ``~/study/python/fastmcp/src`` — no special handling); -#: honoring is delegated to Claude Code, where ``alwaysLoad`` is -#: documented at https://code.claude.com/docs/en/mcp (v2.1.121+). -#: -#: Best-effort by design — safe no-op for clients that don't index the -#: ``anthropic/*`` namespace. Apply only to read-tier discovery anchors -#: (``list_panes``, ``list_windows``, ``snapshot_pane``); each -#: always-loaded tool consumes a fixed schema budget in clients that -#: honour the hint, so widening the set has a real cost. -DISCOVERY_META: dict[str, t.Any] = { - "anthropic/alwaysLoad": True, -} -#: Annotations for tools that stay in the ``mutating`` tier (so they remain -#: visible to default-profile agents) but whose default behaviour can -#: terminate processes or otherwise lose state. -#: -#: Canonical users include ``respawn_pane`` and ``clear_pane``: -#: tier=mutating because shell recovery and scrollback cleanup are part -#: of normal agent workflows, while the hints still disclose process -#: termination or state loss. -#: -#: Distinct from :data:`ANNOTATIONS_DESTRUCTIVE` (same hint values) because -#: the tier tag differs: ``ANNOTATIONS_DESTRUCTIVE`` is paired with -#: ``TAG_DESTRUCTIVE`` everywhere it is used; this preset is paired with -#: ``TAG_MUTATING``. The distinct name documents intent at the call site. -ANNOTATIONS_MUTATING_DESTRUCTIVE: dict[str, bool] = { - "readOnlyHint": False, - "destructiveHint": True, - "idempotentHint": False, - "openWorldHint": False, -} - - -def _tmux_argv(server: Server, *tmux_args: str) -> list[str]: - """Build a full tmux argv list honouring ``socket_name`` and ``socket_path``. - - Internal helper shared by every module that has to invoke the tmux - binary directly via :func:`subprocess.run` (the buffer, wait-for, - and paste_text tools). libtmux's own :meth:`libtmux.Server.cmd` wraps the - same logic but does not expose a timeout, so tools that need - bounded blocking have to shell out themselves — and when they do - they must honour the caller's socket. - - Parameters - ---------- - server : libtmux.server.Server - The resolved server whose socket to target. - *tmux_args : str - tmux subcommand and its flags, e.g. ``"load-buffer", "-b", name``. - - Returns - ------- - list[str] - Complete argv ready for :func:`subprocess.run`. - - Examples - -------- - >>> class _S: - ... tmux_bin = "tmux" - ... socket_name = "s" - ... socket_path = None - >>> _tmux_argv(t.cast("Server", _S()), "list-sessions") - ['tmux', '-L', 's', 'list-sessions'] - - >>> class _P: - ... tmux_bin = "tmux" - ... socket_name = None - ... socket_path = "/tmp/tmux-1000/default" - >>> _tmux_argv(t.cast("Server", _P()), "ls") - ['tmux', '-S', '/tmp/tmux-1000/default', 'ls'] - """ - tmux_bin: str = getattr(server, "tmux_bin", None) or "tmux" - argv: list[str] = [tmux_bin] - if server.socket_name: - argv.extend(["-L", server.socket_name]) - if server.socket_path: - argv.extend(["-S", str(server.socket_path)]) - argv.extend(tmux_args) - return argv - - -_server_cache: dict[tuple[str | None, str | None, str | None], Server] = {} -_server_cache_lock = threading.Lock() - - -def _get_server( - socket_name: str | None = None, - socket_path: str | None = None, -) -> Server: - """Get or create a cached Server instance. - - Parameters - ---------- - socket_name : str, optional - tmux socket name (-L). Falls back to LIBTMUX_SOCKET env var. - socket_path : str, optional - tmux socket path (-S). Falls back to LIBTMUX_SOCKET_PATH env var. - - Returns - ------- - Server - A cached libtmux Server instance. - """ - if socket_name is None: - socket_name = os.environ.get("LIBTMUX_SOCKET") - if socket_path is None: - socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") - - tmux_bin = os.environ.get("LIBTMUX_TMUX_BIN") - - cache_key = (socket_name, socket_path, tmux_bin) - with _server_cache_lock: - if cache_key in _server_cache: - cached = _server_cache[cache_key] - if not cached.is_alive(): - del _server_cache[cache_key] - - if cache_key not in _server_cache: - kwargs: dict[str, t.Any] = {} - if socket_name is not None: - kwargs["socket_name"] = socket_name - if socket_path is not None: - kwargs["socket_path"] = socket_path - if tmux_bin is not None: - kwargs["tmux_bin"] = tmux_bin - _server_cache[cache_key] = Server(**kwargs) - - return _server_cache[cache_key] - - -def _invalidate_server( - socket_name: str | None = None, - socket_path: str | None = None, -) -> None: - """Evict a server from the cache. - - Parameters - ---------- - socket_name : str, optional - tmux socket name used in the cache key. - socket_path : str, optional - tmux socket path used in the cache key. - """ - if socket_name is None: - socket_name = os.environ.get("LIBTMUX_SOCKET") - if socket_path is None: - socket_path = os.environ.get("LIBTMUX_SOCKET_PATH") - - with _server_cache_lock: - keys_to_remove = [ - key - for key in _server_cache - if key[0] == socket_name and key[1] == socket_path - ] - for key in keys_to_remove: - del _server_cache[key] - - -def _resolve_session( - server: Server, - session_name: str | None = None, - session_id: str | None = None, -) -> Session: - """Resolve a session by name or ID. - - Parameters - ---------- - server : Server - The tmux server. - session_name : str, optional - Session name to look up. - session_id : str, optional - Session ID (e.g. '$1') to look up. - - Returns - ------- - Session - - Raises - ------ - exc.TmuxObjectDoesNotExist - If no matching session is found. - """ - if session_id is not None: - session = server.sessions.get(session_id=session_id, default=None) - if session is None: - raise exc.TmuxObjectDoesNotExist( - obj_key="session_id", - obj_id=session_id, - list_cmd="list-sessions", - list_extra_args=(), - ) - return session - - if session_name is not None: - session = server.sessions.get(session_name=session_name, default=None) - if session is None: - raise exc.TmuxObjectDoesNotExist( - obj_key="session_name", - obj_id=session_name, - list_cmd="list-sessions", - list_extra_args=(), - ) - return session - - sessions = server.sessions - if not sessions: - raise exc.TmuxObjectDoesNotExist( - obj_key="session", - obj_id="(any)", - list_cmd="list-sessions", - list_extra_args=(), - ) - return sessions[0] - - -def _resolve_window( - server: Server, - session: Session | None = None, - window_id: str | None = None, - window_index: str | None = None, - session_name: str | None = None, - session_id: str | None = None, -) -> Window: - """Resolve a window by ID, index, or default. - - Parameters - ---------- - server : Server - The tmux server. - session : Session, optional - Session to search within. - window_id : str, optional - Window ID (e.g. '@1'). - window_index : str, optional - Window index within the session. - session_name : str, optional - Session name for resolution. - session_id : str, optional - Session ID for resolution. - - Returns - ------- - Window - - Raises - ------ - exc.TmuxObjectDoesNotExist - If no matching window is found. - """ - if window_id is not None: - window = server.windows.get(window_id=window_id, default=None) - if window is None: - raise exc.TmuxObjectDoesNotExist( - obj_key="window_id", - obj_id=window_id, - list_cmd="list-windows", - list_extra_args=(), - ) - return window - - if session is None: - session = _resolve_session( - server, - session_name=session_name, - session_id=session_id, - ) - - if window_index is not None: - window = session.windows.get(window_index=window_index, default=None) - if window is None: - raise exc.TmuxObjectDoesNotExist( - obj_key="window_index", - obj_id=window_index, - list_cmd="list-windows", - list_extra_args=(), - ) - return window - - windows = session.windows - if not windows: - raise exc.NoWindowsExist() - return windows[0] - - -def _resolve_pane( - server: Server, - pane_id: str | None = None, - session_name: str | None = None, - session_id: str | None = None, - window_id: str | None = None, - window_index: str | None = None, - pane_index: str | None = None, -) -> Pane: - """Resolve a pane by ID or hierarchical targeting. - - Parameters - ---------- - server : Server - The tmux server. - pane_id : str, optional - Pane ID (e.g. '%1'). Globally unique within a server. - session_name : str, optional - Session name for hierarchical resolution. - session_id : str, optional - Session ID for hierarchical resolution. - window_id : str, optional - Window ID for hierarchical resolution. - window_index : str, optional - Window index for hierarchical resolution. - pane_index : str, optional - Pane index within the window. - - Returns - ------- - Pane - - Raises - ------ - exc.TmuxObjectDoesNotExist - If no matching pane is found. - """ - if pane_id is not None: - pane = server.panes.get(pane_id=pane_id, default=None) - if pane is None: - raise exc.PaneNotFound(pane_id=pane_id) - return pane - - window = _resolve_window( - server, - window_id=window_id, - window_index=window_index, - session_name=session_name, - session_id=session_id, - ) - - if pane_index is not None: - pane = window.panes.get(pane_index=pane_index, default=None) - if pane is None: - raise exc.PaneNotFound(pane_id=f"index:{pane_index}") - return pane - - panes = window.panes - if not panes: - raise exc.PaneNotFound() - return panes[0] - - -M = t.TypeVar("M") - - -def _coerce_dict_arg( - name: str, - value: dict[str, t.Any] | str | None, -) -> dict[str, t.Any] | None: - """Coerce a tool parameter to a dict, accepting JSON-string form. - - Workaround: Cursor's composer-1/composer-1.5 models and some other - MCP clients serialize dict params as JSON strings instead of - objects. Claude and GPT models through Cursor work fine; the bug - is model-specific. This helper is the canonical place to absorb - the string form so each tool can stay dict-typed on the Python - side. Callers pass ``name`` so the error messages identify the - offending parameter. - - See: - https://forum.cursor.com/t/145807 - https://github.com/anthropics/claude-code/issues/5504 - - Parameters - ---------- - name : str - Parameter name, used in error messages. - value : dict, str, or None - Either an already-decoded dict, a JSON string of a dict, or - ``None``. - - Returns - ------- - dict or None - The decoded dict, or ``None`` if the input was ``None`` or an - empty string. - - Raises - ------ - ExpectedToolError - If ``value`` is a string that is not valid JSON, or decodes to - a JSON value that is not an object. - """ - if value is None or value == "": - return None - if isinstance(value, str): - try: - decoded = json.loads(value) - except (json.JSONDecodeError, ValueError) as e: - msg = f"Invalid {name} JSON: {e}" - raise ExpectedToolError(msg) from e - if not isinstance(decoded, dict): - msg = f"{name} must be a JSON object, got {type(decoded).__name__}" - raise ExpectedToolError(msg) from None - return decoded - return value - - -def _apply_filters( - items: t.Any, - filters: dict[str, str] | str | None, - serializer: t.Callable[..., M], -) -> list[M]: - """Apply QueryList filters and serialize results. - - Parameters - ---------- - items : QueryList - The QueryList of tmux objects to filter. - filters : dict or str, optional - Django-style filters as a dict (e.g. ``{"session_name__contains": "dev"}``) - or as a JSON string. Some MCP clients require the string form. - If None or empty, all items are returned. - serializer : callable - Serializer function to convert each item to a model. - - Returns - ------- - list - Serialized list of matching items. - - Raises - ------ - ExpectedToolError - If a filter key uses an invalid lookup operator. - """ - coerced = _coerce_dict_arg("filters", filters) - if not coerced: - return [serializer(item) for item in items] - filters = coerced - - valid_ops = sorted(LOOKUP_NAME_MAP.keys()) - for key in filters: - if "__" in key: - _field, op = key.rsplit("__", 1) - if op not in LOOKUP_NAME_MAP: - msg = ( - f"Invalid filter operator '{op}' in '{key}'. " - f"Valid operators: {', '.join(valid_ops)}" - ) - raise ExpectedToolError(msg) - - filtered = items.filter(**filters) - return [serializer(item) for item in filtered] - - -def _serialize_session(session: Session) -> SessionInfo: - """Serialize a Session to a Pydantic model. - - Parameters - ---------- - session : Session - The session to serialize. - - Returns - ------- - SessionInfo - Session data including id, name, window count. - """ - from libtmux_mcp.models import SessionInfo - - assert session.session_id is not None - # Defensive ``getattr``: ``Session.active_pane`` exists on every - # supported libtmux version, but older builds may raise instead of - # returning ``None`` for sessions mid-teardown. Treating a missing - # attribute or missing pane id as ``None`` lets ``list_sessions`` - # tolerate transient state without breaking serialization. - active_pane = getattr(session, "active_pane", None) - active_pane_id = active_pane.pane_id if active_pane is not None else None - - return SessionInfo( - session_id=session.session_id, - session_name=session.session_name, - window_count=len(session.windows), - session_attached=getattr(session, "session_attached", None), - session_created=getattr(session, "session_created", None), - active_pane_id=active_pane_id, - ) - - -def _serialize_window(window: Window) -> WindowInfo: - """Serialize a Window to a Pydantic model. - - Parameters - ---------- - window : Window - The window to serialize. - - Returns - ------- - WindowInfo - Window data including id, name, index, pane count, layout. - """ - from libtmux_mcp.models import WindowInfo - - assert window.window_id is not None - active_pane = getattr(window, "active_pane", None) - active_pane_id = active_pane.pane_id if active_pane is not None else None - - return WindowInfo( - window_id=window.window_id, - window_name=window.window_name, - window_index=window.window_index, - session_id=window.session_id, - session_name=getattr(window, "session_name", None), - pane_count=len(window.panes), - window_layout=getattr(window, "window_layout", None), - window_active=getattr(window, "window_active", None), - window_width=getattr(window, "window_width", None), - window_height=getattr(window, "window_height", None), - active_pane_id=active_pane_id, - ) - - -def _coerce_int(value: str | None) -> int | None: - """Parse a tmux format-string number into ``int`` or ``None``. - - tmux format variables come back as strings; an empty string means - "tmux returned nothing" (e.g. older tmux that doesn't know the var). - """ - if value is None or value == "": - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _coerce_bool(value: str | None) -> bool | None: - """Parse a tmux ``"1"``/``"0"`` flag into ``bool`` or ``None``. - - Mirrors libtmux's own ``Pane.at_top`` / ``at_bottom`` typing, which - folds ``"1"`` to True and everything else to False — except we keep - ``None`` distinct so callers can tell "tmux didn't tell us" from - "tmux said no". - """ - if value is None or value == "": - return None - return value == "1" - - -def _serialize_pane(pane: Pane) -> PaneInfo: - """Serialize a Pane to a Pydantic model. - - Parameters - ---------- - pane : Pane - The pane to serialize. - - Returns - ------- - PaneInfo - Pane data including id, dimensions, geometry, current command, title. - """ - from libtmux_mcp.models import PaneInfo - - assert pane.pane_id is not None - return PaneInfo( - pane_id=pane.pane_id, - pane_index=getattr(pane, "pane_index", None), - pane_width=getattr(pane, "pane_width", None), - pane_height=getattr(pane, "pane_height", None), - pane_left=_coerce_int(getattr(pane, "pane_left", None)), - pane_top=_coerce_int(getattr(pane, "pane_top", None)), - pane_right=_coerce_int(getattr(pane, "pane_right", None)), - pane_bottom=_coerce_int(getattr(pane, "pane_bottom", None)), - pane_at_left=_coerce_bool(getattr(pane, "pane_at_left", None)), - pane_at_right=_coerce_bool(getattr(pane, "pane_at_right", None)), - pane_at_top=_coerce_bool(getattr(pane, "pane_at_top", None)), - pane_at_bottom=_coerce_bool(getattr(pane, "pane_at_bottom", None)), - pane_tty=getattr(pane, "pane_tty", None), - pane_current_command=getattr(pane, "pane_current_command", None), - pane_current_path=getattr(pane, "pane_current_path", None), - pane_pid=getattr(pane, "pane_pid", None), - pane_title=getattr(pane, "pane_title", None), - pane_active=getattr(pane, "pane_active", None), - window_id=pane.window_id, - session_id=pane.session_id, - is_caller=_compute_is_caller(pane), - ) - - -P = t.ParamSpec("P") -R = t.TypeVar("R") - - -def _map_exception_to_tool_error(fn_name: str, e: BaseException) -> ToolError: - """Translate a libtmux / unexpected exception into a ``ToolError``. - - Shared between the sync and async ``handle_tool_errors*`` decorators - so the two paths stay byte-for-byte identical in what agents see. - - Expected, agent-correctable failures map to - :class:`ExpectedToolError` (logged at WARNING). Two cases stay at - ERROR: a missing tmux binary (operator-environment fault that must - be loud) and the unexpected catch-all (potential bug in this - server). - """ - if isinstance(e, exc.TmuxCommandNotFound): - msg = "tmux binary not found. Ensure tmux is installed and in PATH." - return ToolError(msg) - if isinstance(e, exc.TmuxSessionExists): - return ExpectedToolError(str(e)) - if isinstance(e, exc.BadSessionName): - return ExpectedToolError(str(e)) - if isinstance(e, exc.ObjectDoesNotExist): - return ExpectedToolError( - f"Object not found: {e}", - suggestion=( - "Call list_sessions / list_windows / list_panes to discover valid ids." - ), - ) - if isinstance(e, exc.MultipleObjectsReturned): - return ExpectedToolError( - f"Ambiguous target: {e}", - suggestion=( - "A window shared between sessions is listed once per session that " - "holds it, so a name or index can match more than one row. Target " - "it by id (session_id / window_id / pane_id) instead." - ), - ) - if isinstance(e, exc.PaneNotFound): - return ExpectedToolError( - f"Pane not found: {e}", - suggestion="Call list_panes to discover valid pane ids.", - ) - if isinstance(e, exc.LibTmuxException): - return ExpectedToolError(f"tmux error: {e}") - logger.exception("unexpected error in MCP tool %s", fn_name) - return ToolError(f"Unexpected error: {type(e).__name__}: {e}") - - -def handle_tool_errors( - fn: t.Callable[P, R], -) -> t.Callable[P, R]: - """Decorate synchronous MCP tool functions with standardized error handling. - - Catches libtmux exceptions and re-raises them through - :func:`_map_exception_to_tool_error` so MCP responses have - ``isError=True`` with a descriptive message — expected, - agent-correctable failures as :class:`ExpectedToolError` (logged - at WARNING), the unexpected catch-all as stock ``ToolError`` - (logged at ERROR). - - The re-raise chains the original exception via ``from e``. Keep it - single-level: :class:`~libtmux_mcp.middleware.ReadonlyRetryMiddleware` - matches :exc:`libtmux.exc.LibTmuxException` by inspecting exactly - one ``__cause__`` hop, so wrapping the mapped error again would - silently disable readonly retries. - - Use :func:`handle_tool_errors_async` for ``async def`` tools — this - wrapper only supports plain sync callables. - """ - - @functools.wraps(fn) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - try: - return fn(*args, **kwargs) - except ToolError: - raise - except Exception as e: - raise _map_exception_to_tool_error(fn.__name__, e) from e - - return wrapper - - -def handle_tool_errors_async( - fn: t.Callable[P, t.Coroutine[t.Any, t.Any, R]], -) -> t.Callable[P, t.Coroutine[t.Any, t.Any, R]]: - """Decorate asynchronous MCP tool functions with standardized error handling. - - Async counterpart to :func:`handle_tool_errors`. Required for tools - that accept a :class:`fastmcp.Context` parameter because Context's - ``report_progress``/``elicit``/``read_resource`` methods are - coroutines that only run inside ``async def`` tools. - - Maps the same libtmux exception set to the same messages and - error classes as the sync decorator (expected failures as - :class:`ExpectedToolError` at WARNING, the unexpected catch-all as - stock ``ToolError`` at ERROR) by delegating to a shared helper, - and chains the original exception via the same single-level - ``from e`` that readonly retries depend on. - """ - - @functools.wraps(fn) - async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - try: - return await fn(*args, **kwargs) - except ToolError: - raise - except Exception as e: - raise _map_exception_to_tool_error(fn.__name__, e) from e - - return wrapper diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 9226bf2a..2fbc1e67 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -28,10 +28,13 @@ from __future__ import annotations import hashlib +import hmac import logging +import secrets import time import typing as t +from fastmcp.exceptions import PromptError, ResourceError from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.middleware.error_handling import ( ErrorHandlingMiddleware, @@ -40,36 +43,75 @@ from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware from fastmcp.tools.base import ToolResult from libtmux import exc as libtmux_exc -from mcp.types import CallToolRequestParams, TextContent +from mcp import McpError +from mcp.types import CallToolRequestParams, ErrorData, TextContent from pydantic import ValidationError as PydanticValidationError -from libtmux_mcp._utils import ( +from libtmux_mcp._errors import ExpectedToolError +from libtmux_mcp._safety import ( TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, TAG_SELF_BOUNDED, +) + +#: Errors describing a CALLER-caused failure, which must never reach the +#: ``-32603`` "Internal error" path whichever handler raised them: +#: ``ExpectedToolError`` from tools, ``ResourceError`` / ``PromptError`` +#: from resources and prompts. +_CALLER_CAUSED_ERRORS: tuple[type[Exception], ...] = ( ExpectedToolError, + ResourceError, + PromptError, ) +logger = logging.getLogger(__name__) + _TIER_LEVELS: dict[str, int] = { TAG_READONLY: 0, TAG_MUTATING: 1, TAG_DESTRUCTIVE: 2, } +#: Reverse of :data:`_TIER_LEVELS`, so a middleware configured with an +#: unrecognized tier can still *name* the tier it fell back to. +_LEVEL_TIERS: dict[int, str] = {level: tier for tier, level in _TIER_LEVELS.items()} + + +def _highest_tier(tags: t.Collection[str]) -> str | None: + """Return the highest safety tier named in *tags*, or None if untagged.""" + found = [tier for tier in _TIER_LEVELS if tier in tags] + if not found: + return None + return max(found, key=lambda tier: _TIER_LEVELS[tier]) + class SafetyMiddleware(Middleware): - """Gate tools by safety tier. + """Explain tier denials that ``_enable_allowed_tools`` enforces. + + FastMCP's native ``disable()`` is the enforcement gate and holds + even for a call that skips the middleware chain. It also makes + ``get_tool()`` answer **None**, so FastMCP reports an off-tier call + as ``Unknown tool`` -- the server denying its own gated tool exists. + This gate names the required tier instead, resolving such names + against :meth:`_tier_snapshot` (the registry keeps disabled tools). + + Denials must raise *here* so :class:`AuditMiddleware`, which sits + outside, records them as denials rather than unknown-tool errors. Parameters ---------- max_tier : str - Maximum allowed tier. One of ``TAG_READONLY``, ``TAG_MUTATING``, - or ``TAG_DESTRUCTIVE``. + Maximum allowed tier. Unrecognized values fall back to + ``TAG_READONLY``. """ def __init__(self, max_tier: str = TAG_MUTATING) -> None: self.max_level = _TIER_LEVELS.get(max_tier, 0) + #: Normalized tier name, so a denial reports where the server + #: stands rather than echoing an unrecognized env value back. + self.max_tier = _LEVEL_TIERS[self.max_level] + self._tier_by_tool: dict[str, str] | None = None def _is_allowed(self, tags: set[str]) -> bool: """Return True if the tool's tags fall within the allowed tier. @@ -84,6 +126,55 @@ def _is_allowed(self, tags: set[str]) -> bool: return False return found_tier + def _denial_message(self, tool_name: str, required_tier: str | None) -> str: + """Name the required tier and the active one. + + The message this replaced hardcoded ``destructive`` for every + denial, so a readonly server answered ``send_keys`` by advising + kill_server rights in order to type into a pane. + """ + if required_tier is None: + return ( + f"Tool {tool_name!r} declares no safety tier and is blocked. " + "This is a bug in the server, not a configuration problem; " + "please report it." + ) + return ( + f"Tool {tool_name!r} requires safety level {required_tier!r}, but " + f"this server is running at {self.max_tier!r}. Restart it with " + f"LIBTMUX_SAFETY={required_tier} to enable it." + ) + + async def _tier_snapshot(self, fastmcp: t.Any) -> dict[str, str]: + """Map every registered tool name to its tier, disabled included. + + Built once. ``_list_tools()`` is FastMCP-private, so a failure + degrades to an empty map: the call then falls through to the + stock ``NotFoundError``, losing the explanation but nothing + else. ``tests/test_server.py`` pins the behavior so a fastmcp + bump fails in CI rather than silently reverting this gate. + """ + if self._tier_by_tool is not None: + return self._tier_by_tool + + snapshot: dict[str, str] = {} + try: + registered = await fastmcp._list_tools() + except Exception: + logger.warning( + "safety tier snapshot unavailable; off-tier calls will " + "report 'unknown tool'", + exc_info=True, + ) + else: + for tool in registered: + tier = _highest_tier(tool.tags) + if tier is not None: + snapshot[tool.name] = tier + + self._tier_by_tool = snapshot + return snapshot + async def on_list_tools( self, context: MiddlewareContext, @@ -98,16 +189,36 @@ async def on_call_tool( context: MiddlewareContext, call_next: t.Any, ) -> t.Any: - """Block execution of tools above the safety tier.""" - if context.fastmcp_context: - tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) - if tool and not self._is_allowed(tool.tags): - msg = ( - f"Tool '{context.message.name}' is not available at the " - f"current safety level. Set LIBTMUX_SAFETY=destructive " - f"to enable destructive tools." + """Block execution of tools above the safety tier. + + Fail-closed except for a name the registry has never heard of, + which is a typo and deserves FastMCP's own ``NotFoundError``. + """ + tool_name = context.message.name + + if context.fastmcp_context is None: + # No registry to consult: deny, since the top tier includes + # kill_server. + msg = ( + f"Tool {tool_name!r} was called without a FastMCP context, so " + "its safety tier cannot be verified. Call it through MCP." + ) + raise ExpectedToolError(msg) + + fastmcp = context.fastmcp_context.fastmcp + + tool = await fastmcp.get_tool(tool_name) + if tool is not None: + if not self._is_allowed(tool.tags): + raise ExpectedToolError( + self._denial_message(tool_name, _highest_tier(tool.tags)) ) - raise ExpectedToolError(msg) + return await call_next(context) + + # Invisible to ``get_tool``: gated by tier, or nonexistent. + gated_tier = (await self._tier_snapshot(fastmcp)).get(tool_name) + if gated_tier is not None: + raise ExpectedToolError(self._denial_message(tool_name, gated_tier)) return await call_next(context) @@ -136,7 +247,7 @@ def _is_schema_validation_error(error: BaseException) -> bool: too early for the ``handle_tool_errors`` decorators to classify. Bad arguments are agent-correctable (fix the call and retry), so they get the same expected/WARNING treatment as - :class:`~libtmux_mcp._utils.ExpectedToolError`. + :class:`~libtmux_mcp._errors.ExpectedToolError`. Output validation cannot be mistaken for this case: fastmcp's tool layer converts output-shape failures into error results itself, so @@ -212,14 +323,11 @@ def install_fastmcp_validation_log_filter() -> None: logger.addFilter(_FastMCPValidationLogFilter()) -#: Scheduling flag some MCP clients (notably Gemini CLI when batching -#: several tool calls in one turn) merge into the tool's arguments. -#: Recognized only to *word the rejection helpfully* — the argument is -#: still rejected, never silently stripped, so genuine argument typos -#: from other clients stay loud. Contrast MemPalace/mempalace#322, -#: which strips the key, and #647, which whitelists arguments against -#: the schema — silent dropping would let a mis-named flag on a -#: mutating tool (e.g. ``enter`` on send_keys) run with defaults. +#: Scheduling flag some MCP clients (notably Gemini CLI batching several +#: tool calls in one turn) merge into the tool's arguments. Recognized +#: only to word the rejection helpfully: the argument is still rejected, +#: never silently stripped, so a mis-named flag on a mutating tool +#: (``enter`` on send_keys) cannot run with defaults. _CLIENT_SCHEDULING_FLAG = "wait_for_previous" @@ -293,7 +401,7 @@ def _error_tool_result( (``__cause__`` when the raise site chained one, so agents see ``PaneNotFound`` rather than the ``ToolError`` wrapper). * ``expected`` — True for agent-correctable failures - (:class:`~libtmux_mcp._utils.ExpectedToolError` and + (:class:`~libtmux_mcp._errors.ExpectedToolError` and argument-schema validation errors), False for operator faults and potential server bugs. * ``suggestion`` — recovery hint. Carried by the error when the @@ -366,7 +474,7 @@ class ToolErrorResultMiddleware(ErrorHandlingMiddleware): Logging honors ``FastMCPError.log_level`` (fastmcp >= 3.3): the expected failures demoted to WARNING by - :class:`~libtmux_mcp._utils.ExpectedToolError` no longer get + :class:`~libtmux_mcp._errors.ExpectedToolError` no longer get re-shouted at ERROR by the stock ``_log_error``. Argument-schema validation failures — raised by fastmcp before tool code can classify them — are treated as expected too (see @@ -430,6 +538,34 @@ def _log_error(self, error: Exception, context: MiddlewareContext) -> None: except Exception: self.logger.exception("Error in error callback") + def _transform_error( + self, + error: Exception, + context: MiddlewareContext, + ) -> Exception: + """Keep caller-caused failures out of the ``-32603`` catch-all. + + The base transform funnels every unrecognized exception into + ``"Internal error: ..."``. That is right for a bug and wrong for + "that session does not exist". It also runs for EVERY message + kind, so intercepting only ``tools/call`` below left resources + reporting a caller's mistake as a server fault: + ``tmux://sessions/nosuchsession`` answered + ``Internal error: Session not found: nosuchsession``. + + Fixed at the fork rather than by adding one resource hook. The + property is "an expected failure is never an internal error", + and it has to hold on every path this transform serves. + """ + if self.transform_errors and isinstance(error, _CALLER_CAUSED_ERRORS): + # -32002 is the MCP code for a resource miss, -32602 for bad + # caller arguments. Neither prefixes the message, which + # already names the object that was not found. + method = context.method or "" + code = -32002 if method.startswith("resources/") else -32602 + return McpError(ErrorData(code=code, message=str(error))) + return super()._transform_error(error, context) + async def on_call_tool( self, context: MiddlewareContext, @@ -447,31 +583,49 @@ async def on_call_tool( # Audit middleware # --------------------------------------------------------------------------- -#: Argument names that carry user-supplied payloads we never want in logs. -#: ``keys`` (send_keys), ``text`` (paste_text), ``command`` (run_command), -#: ``value`` (set_environment), ``content`` (load_buffer), ``shell`` -#: (respawn_pane), and ``environment`` (respawn_pane) can contain commands, -#: secrets, or arbitrary large strings. -#: Matched by exact name, case-sensitive, to mirror the tool signatures. +#: Argument names carrying user-supplied payloads that must not reach the +#: log. Matched by exact name, case-sensitive, to mirror the signatures. +#: +#: ``environment`` as a mapping digests each *value* and leaves its *keys* +#: (``DATABASE_URL``) visible; as a JSON object string it is redacted as +#: one scalar digest, so its keys are not retained. +#: +#: ``pattern``/``patterns``/``stop`` carry what the caller is looking FOR, +#: and the realistic reason to look for a credential is to check whether +#: one leaked. Redacting delivery while logging verification protects +#: neither. #: -#: ``environment`` accepts a mapping or a JSON object string. The redaction -#: logic in :func:`_summarize_args` digests each mapping *value* while leaving -#: its *keys* (env var names like ``DATABASE_URL``) visible. A JSON string is -#: instead redacted as one scalar digest, so its keys are not retained. +#: Scope is the audit log. ``shell`` and ``environment`` values may still +#: surface in the OS process table and ``pane_current_command`` until the +#: spawned shell takes over -- see ``docs/topics/safety.md``. #: -#: Note on ``shell`` and ``environment`` redaction: this redacts the MCP -#: audit log only. ``respawn_pane(shell="env SECRET=... bash")`` and -#: ``environment={"AWS_SECRET_KEY": "..."}`` may briefly expose the values -#: via the OS process table and tmux's ``pane_current_command`` metadata -#: until the spawned shell takes over — see ``docs/topics/safety.md``. +#: ``output_path``, ``start_directory`` and the name arguments are +#: decided: a path is legitimate audit content. This set defaults to "log +#: it", so a free-text argument added later is exposed by omission unless +#: it is weighed against those two registers and added here. _SENSITIVE_ARG_NAMES: frozenset[str] = frozenset( - {"keys", "text", "command", "value", "content", "shell", "environment"} + { + "keys", + "text", + "command", + "value", + "content", + "shell", + "environment", + "pattern", + "patterns", + "stop", + # A caller-supplied MATCH EXPRESSION, same category as ``pattern``: + # {"pane_title__contains": ...} says what the caller hunted for. + # Both accepted shapes need it -- a dict, and the JSON string that + # ``_coerce_dict_arg`` also takes. + "filters", + } ) -#: Nested argument containers that may contain sensitive argument names. -#: ``operations`` is used by ``send_keys_batch`` and the generic tool-batch -#: wrappers. Preserving routing metadata is useful for audit trails, but -#: nested payloads must be digested the same way top-level tool calls are. +#: Nested argument containers that may hold sensitive argument names. +#: Routing metadata stays readable for the audit trail; nested payloads +#: are digested the same way top-level ones are. _NESTED_ARG_LIST_NAMES: frozenset[str] = frozenset({"operations"}) _NONE_TYPE = type(None) @@ -493,23 +647,45 @@ async def on_call_tool( _MAX_LOGGED_STR_LEN: int = 200 +#: Keyed per PROCESS, not per payload. A plain SHA-256 beside an exact +#: length is reversible for anything short -- the length fixes the search +#: space, and agents type PINs and 2FA codes into panes. +#: +#: Identical payloads still correlate within a server run, the scope an +#: operator reads a log at; correlation ACROSS runs is what this trades +#: away, in exchange for a log reader being unable to test a guess. +_REDACTION_KEY: bytes = secrets.token_bytes(32) + + def _redact_digest(value: str) -> dict[str, t.Any]: - """Return a length + SHA-256 prefix summary of ``value``. + """Return a length + keyed-digest summary of ``value``. - The digest is stable and deterministic, which lets operators - correlate the same payload across log lines without ever recording - the payload itself. + The field is ``digest`` and not ``sha256_prefix`` on purpose. It + holds an HMAC under a per-process key, so an operator who read the + old name and computed ``sha256(candidate)`` would find no match and + conclude two equal payloads DIFFERED -- a silent wrong answer in the + one use case the digest exists to serve. A name that fixes the + algorithm also has to change every time the algorithm does. + + Stable within one server run, so operators can correlate the same + payload across log lines. NOT reproducible from the payload alone — + see :data:`_REDACTION_KEY` for why that matters. Examples -------- - >>> _redact_digest("hello") - {'len': 5, 'sha256_prefix': '2cf24dba5fb0'} - >>> _redact_digest("") - {'len': 0, 'sha256_prefix': 'e3b0c44298fc'} + >>> summary = _redact_digest("hello") + >>> summary["len"], len(summary["digest"]) + (5, 12) + >>> _redact_digest("hello") == summary # correlates within the run + True + >>> _redact_digest("hellp") == summary # and only for equal payloads + False """ return { "len": len(value), - "sha256_prefix": hashlib.sha256(value.encode("utf-8")).hexdigest()[:12], + "digest": hmac.new( + _REDACTION_KEY, value.encode("utf-8"), hashlib.sha256 + ).hexdigest()[:12], } @@ -588,6 +764,12 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: summary[key] = _redact_digest(value) elif key in _SENSITIVE_ARG_NAMES and isinstance(value, dict): summary[key] = {k: _redact_digest(str(v)) for k, v in value.items()} + elif key in _SENSITIVE_ARG_NAMES and isinstance(value, list): + # Coerced like the dict branch rather than redacting only + # ``str`` items: every sensitive list is ``list[str]`` today, + # so a type test works right up until an annotation widens and + # values start reaching the log with nothing failing. + summary[key] = [_redact_digest(str(item)) for item in value] elif key in _NESTED_ARG_LIST_NAMES: if isinstance(value, list): summary[key] = [ @@ -673,21 +855,18 @@ async def on_call_tool( # Tail-preserving response limiter # --------------------------------------------------------------------------- -#: Default byte ceiling for :class:`TailPreservingResponseLimitingMiddleware`. -#: Matches FastMCP's stock 1 MB default so normal schema-bearing tool -#: responses stay below this global backstop. Tool-level caps remain -#: responsible for terminal-specific truncation metadata. +#: Default byte ceiling for :class:`TailPreservingResponseLimitingMiddleware`, +#: matching FastMCP's stock 1 MB. Tool-level caps stay responsible for +#: terminal-specific truncation metadata. DEFAULT_RESPONSE_LIMIT_BYTES = 1_000_000 -#: Failures a retry cannot fix. Each one names a thing that is not there, or a -#: request that cannot succeed as written, so re-running it buys a second tmux -#: round-trip and a backoff window in order to fail identically. They all -#: descend from :exc:`libtmux.exc.LibTmuxException`, which is the retry -#: trigger, so without this set they would all be retried. -#: -#: Order the entries most-general-first when reading: ``ObjectDoesNotExist`` -#: already covers :exc:`libtmux.exc.TmuxObjectDoesNotExist`. +#: Failures a retry cannot fix: each names a thing that is not there, or a +#: request that cannot succeed as written, so re-running buys a round-trip +#: and a backoff before failing identically. All descend from +#: :exc:`libtmux.exc.LibTmuxException`, the retry trigger, so without this +#: set every one would be retried. ``ObjectDoesNotExist`` already covers +#: :exc:`libtmux.exc.TmuxObjectDoesNotExist`. NON_RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = ( libtmux_exc.ObjectDoesNotExist, libtmux_exc.MultipleObjectsReturned, @@ -812,6 +991,34 @@ async def on_call_tool( _TRUNCATION_HEADER_TEMPLATE = "[... truncated {dropped} bytes ...]\n" +def _restructure_truncated( + original: dict[str, t.Any], + truncated: ToolResult, +) -> ToolResult | None: + """Re-attach structured content to a truncated success result. + + Only the single-string result shape is rebuildable: fastmcp wraps a + ``-> str`` tool as ``{"result": "..."}``, so swapping in the + truncated text keeps the payload schema-valid. Model- and + list-shaped results carry the oversize inside their own fields and + cannot be trimmed from the flattened text, so they return None and + the caller reports a tool error instead of an invalid response. + """ + if set(original) != {"result"} or not isinstance(original["result"], str): + return None + text = next( + (block.text for block in truncated.content if isinstance(block, TextContent)), + None, + ) + if text is None: + return None + return ToolResult( + content=truncated.content, + structured_content={"result": text}, + meta=truncated.meta, + ) + + class TailPreservingResponseLimitingMiddleware(ResponseLimitingMiddleware): """Response-limiter that keeps the tail of oversized output. @@ -860,15 +1067,39 @@ async def _capture( return t.cast("ToolResult", inner) result = await super().on_call_tool(context, _capture) - if result is not inner and isinstance(inner, ToolResult) and inner.is_error: - # The base class truncated and rebuilt the result; restore - # the error flag it dropped. + if result is inner or not isinstance(inner, ToolResult): + return result + + # The base class truncated and rebuilt the result, dropping both + # ``is_error`` and ``structured_content``. + if inner.is_error: return ToolResult( content=result.content, meta=result.meta, is_error=True, ) - return result + if inner.structured_content is None: + return result + + # A tool that declares an output schema and gets a truncated result + # with no structured content makes a spec-compliant client raise a + # transport error instead of delivering the truncated data. + rebuilt = _restructure_truncated(inner.structured_content, result) + if rebuilt is not None: + return rebuilt + # Shape we cannot rebuild: say so as a tool error the agent can + # act on, rather than emitting a response its client will reject. + msg = ( + "Response exceeded the server's size limit and could not be " + "truncated while satisfying this tool's output schema. Re-run " + "with a narrower range (for example a smaller max_lines, or a " + "less negative start)." + ) + return ToolResult( + content=[TextContent(type="text", text=msg)], + meta=result.meta, + is_error=True, + ) def _truncate_to_result( self, diff --git a/src/libtmux_mcp/models.py b/src/libtmux_mcp/models.py index 90733671..b68e901f 100644 --- a/src/libtmux_mcp/models.py +++ b/src/libtmux_mcp/models.py @@ -109,6 +109,14 @@ class PaneInfo(BaseModel): default=None, description="Active flag ('1' or '0')" ) window_id: str | None = Field(default=None, description="Parent window ID") + session_name: str | None = Field( + default=None, + description=( + "Session the pane is in. Present because a pane can change " + "session -- break_pane and join_pane both move panes across " + "them -- and session_id alone does not name it." + ), + ) session_id: str | None = Field(default=None, description="Parent session ID") is_caller: bool | None = Field( default=None, @@ -200,19 +208,89 @@ class ServerInfo(BaseModel): socket_path: str | None = Field(default=None, description="Socket path") session_count: int = Field(description="Number of sessions") version: str | None = Field(default=None, description="tmux version") + unreachable_reason: str | None = Field( + default=None, + description=( + "Why a server that exists could not be queried, e.g. this tmux " + "binary being older than the one that created the socket. When " + "set, is_alive=False means 'could not ask', NOT 'not running', " + "and session_count=0 carries no information." + ), + ) + + +class SplitResult(PaneInfo): + """The new pane, plus what the split did to the pane it split. + + Extends :class:`PaneInfo` rather than nesting it, so every field a + caller already read stays exactly where it was. + + ``size`` names the NEW pane, so the source's post-split extent is + the number a caller needs to plan the NEXT split -- and it was the + one thing the response did not carry. Building three equal panes + across 236 columns means splitting the 157-column remainder, not + the 78-column pane that is already right; without ``source_pane`` + that choice needs a ``list_panes`` round trip between every pair of + splits, and a caller who does not know to make it gets the wrong + layout silently, because each individual response was true. + """ + + source_pane: PaneInfo | None = Field( + default=None, + description=( + "The pane that was split, as it stands AFTER the split. " + "Its extent is what constrains the next split of the same " + "region. ``null`` only if tmux stopped reporting it." + ), + ) class OptionResult(BaseModel): """Result of a show_option call.""" option: str = Field(description="Option name") - value: t.Any = Field(description="Option value") + value: t.Any = Field( + description=( + "Option value. ``null`` means NOT SET AT THE SCOPE QUERIED, " + "which is not the same as not set: an option inherited from " + "a wider scope reads as null unless ``include_inherited`` " + "was passed." + ) + ) + resolved_target: str | None = Field( + default=None, + description=( + "Session tmux resolved an untargeted query to. tmux picks a " + "'current' session from attached clients, and an MCP client has " + "none, so which one answered is not otherwise knowable." + ), + ) + scope_queried: str = Field( + default="server", + description="Scope this answer describes, so null is readable.", + ) + include_inherited: bool = Field( + default=False, + description=( + "True when inherited values were resolved (tmux ``-A``), so " + "the value is the one in force rather than only one set at " + "this scope." + ), + ) class OptionSetResult(BaseModel): """Result of a set_option call.""" - option: str = Field(description="Option name") + option: str = Field(description="Option name as supplied by the caller") + resolved_option: str | None = Field( + default=None, + description=( + "Option tmux resolved the name to. tmux accepts unambiguous " + "prefixes, so 'history-lim' sets 'history-limit' and the caller " + "would otherwise have no way to confirm which option changed." + ), + ) value: str = Field(description="Value that was set") status: str = Field(description="Operation status") @@ -220,15 +298,57 @@ class OptionSetResult(BaseModel): class EnvironmentResult(BaseModel): """Result of a show_environment call.""" - variables: dict[str, str | bool] = Field(description="Environment variable mapping") + scope_queried: str = Field( + default="global", + description="Scope the variables were read from: 'global' or 'session'.", + ) + variables: dict[str, str] = Field( + description="Variables that are SET, mapped to their values." + ) + removed: list[str] = Field( + default_factory=list, + description=( + "Names tmux marks as explicitly REMOVED from the environment " + "(it prints them as ``-NAME``). These are not in ``variables``. " + "Distinct from a name that simply never appears, which tmux " + "does not report at all." + ), + ) + include_inherited: bool = Field( + default=False, + description=( + "Whether the global environment was merged underneath a " + "session's. When false and ``scope_queried`` is 'session', " + "``variables`` omits globals that new panes in that session " + "will still receive." + ), + ) class EnvironmentSetResult(BaseModel): """Result of a set_environment call.""" name: str = Field(description="Variable name") - value: str = Field(description="Value that was set") - status: str = Field(description="Operation status") + value: str | None = Field( + default=None, + description="Value that was set; null when the variable was unset", + ) + status: str = Field( + description=( + "'set'; 'unset' when a variable was removed; 'absent' when there " + "was nothing to remove AT THE SCOPE TARGETED -- see " + "``still_set_globally``." + ) + ) + still_set_globally: bool = Field( + default=False, + description=( + "True when a session-scoped unset reported 'absent' but the " + "name is still set in the GLOBAL environment, so new panes " + "keep receiving it. Without this, 'never existed' and " + "'still in force everywhere' are the same answer." + ), + ) class WaitForTextResult(BaseModel): @@ -299,6 +419,17 @@ class WaitForTextResult(BaseModel): "``alternate_screen``." ), ) + stop_matched_at_entry: bool = Field( + default=False, + description=( + "True when a ``stop`` pattern was already on screen before the " + "wait began. The wait only stops on a FRESH stop hit, so this " + "does not end it -- but a failure marker left from an earlier " + "run is the usual reason a ``timeout`` outcome is misread as " + "'still running'. Read it as: check whether you are waiting on " + "a run that already failed." + ), + ) matched_at_entry: bool = Field( default=False, description=( @@ -366,6 +497,18 @@ class RunCommandResult(BaseModel): description="Shell exit status, or None when the command timed out", ) timed_out: bool = Field(description="True when the wait timed out") + command_may_still_run: bool = Field( + default=False, + description=( + "True when the wait timed out, meaning the command was SENT " + "but not observed to finish. It is not cancelled: the " + "keystrokes sit in the pane's input buffer and the shell " + "runs them whenever it next reads a line, which may be long " + "after this call returned. Do NOT retry a non-idempotent " + "command on this result -- that is how a `git push` or a " + "migration runs twice. Check the pane first." + ), + ) elapsed_seconds: float = Field(description="Time spent waiting in seconds") output: list[str] = Field( default_factory=list, @@ -399,7 +542,11 @@ class SendKeysOperation(BaseModel): keys: str = Field(description="Keys or text to send.") pane_id: str | None = Field( default=None, - description="Pane ID (e.g. '%1').", + description=( + "Pane ID (e.g. '%1'). Each operation must name its own target " + "-- one of pane_id / session_id / session_name / window_id -- " + "and is refused individually if it does not." + ), ) session_name: str | None = Field( default=None, @@ -533,10 +680,51 @@ class ToolCallBatchResult(BaseModel): ) +class PaneMoveResult(BaseModel): + """Result of moving a pane between windows. + + Carries the source-window outcome because the move can DESTROY it: + a window with no panes left is removed by tmux, so consolidating + panes deletes windows the caller never named. The pane alone cannot + express that -- it reports only where it landed. + """ + + pane: PaneInfo = Field(description="The pane after the move.") + source_window_id: str | None = Field( + default=None, description="Window the pane was moved out of." + ) + source_window_destroyed: bool = Field( + default=False, + description=( + "Whether the source window was removed because this move left " + "it with no panes." + ), + ) + + class PaneSnapshot(BaseModel): """Rich screen capture with metadata: content, cursor, mode, and scroll state.""" pane_id: str = Field(description="Pane ID (e.g. '%1')") + session_id: str | None = Field( + default=None, + description=( + "Session the pane is in. Present because the server " + "instructions recommend this tool over capture_pane + " + "get_pane_info, and without it that substitution cannot " + "answer which session a pane ended up in -- which break_pane " + "can change." + ), + ) + window_id: str | None = Field( + default=None, description="Window the pane is in (e.g. '@1')." + ) + pane_index: str | None = Field( + default=None, description="Pane index within its window." + ) + pane_active: bool | None = Field( + default=None, description="Whether this is the window's active pane." + ) content: str = Field(description="Visible pane text") cursor_x: int = Field(description="Cursor column (0-based)") cursor_y: int = Field(description="Cursor row (0-based)") @@ -646,11 +834,24 @@ class SearchPanesResult(BaseModel): default_factory=list, description="PaneContentMatch entries for this page.", ) + searched_scope: t.Literal["visible", "scrollback"] = Field( + default="visible", + description=( + "How much of each pane was read. ``visible`` is the default " + "and means ONLY the on-screen rows were searched, so a match " + "that has scrolled off is not reported and ``matches: []`` " + "does not mean the text is absent. Pass ``content_start`` " + "(e.g. -500) to search scrollback." + ), + ) truncated: bool = Field( default=False, description=( "True when the result set was truncated by ``limit`` or " - "by ``max_matched_lines_per_pane`` on any pane." + "by ``max_matched_lines_per_pane`` on any pane. It describes " + "those caps ONLY -- it never reports rows left unread " + "because the search was scoped to the visible screen; read " + "``searched_scope`` for that." ), ) truncated_panes: list[str] = Field( @@ -671,6 +872,38 @@ class SearchPanesResult(BaseModel): limit: int | None = Field(description="The ``limit`` that produced this page.") +class ClientInfo(BaseModel): + """One client attached to a tmux server. + + Read with an explicit ``list-clients -F`` rather than through + libtmux, whose ``Server.clients`` leaves ``client_tty`` and + ``client_pid`` unset -- the same family of unpopulated ``client_*`` + attributes that makes them unusable as filter fields. + """ + + client_tty: str | None = Field(default=None, description="Client tty path.") + client_pid: int | None = Field(default=None, description="Client process id.") + session_name: str | None = Field( + default=None, description="Session this client is attached to." + ) + width: int | None = Field(default=None, description="Client width in columns.") + height: int | None = Field(default=None, description="Client height in rows.") + term_name: str | None = Field(default=None, description="Client TERM value.") + control_mode: bool = Field( + default=False, description="Whether the client is in control mode." + ) + readonly: bool = Field(default=False, description="Whether the client is readonly.") + + +class ClientListResult(BaseModel): + """Result of a list_clients call.""" + + clients: list[ClientInfo] = Field(description="Clients attached to the server.") + socket_name: str | None = Field( + default=None, description="Socket the clients were read from." + ) + + class HookEntry(BaseModel): """One entry in a tmux hook array. @@ -702,6 +935,24 @@ class HookListResult(BaseModel): """ entries: list[HookEntry] = Field(default_factory=list) + include_inherited: bool = Field( + default=False, + description=( + "Whether wider scopes were consulted. When false, an empty " + "``entries`` means 'not set at this scope', NOT 'not set' -- " + "a hook set globally is in force and still reads as empty." + ), + ) + resolved_target: str | None = Field( + default=None, + description=( + "tmux id the query was answered for, or ``null`` for a " + "server-scope query, which has no target. Present so an " + "untargeted call says which object it picked -- the default " + "is the oldest session on the server, which need not be the " + "one the caller has in mind." + ), + ) class BufferRef(BaseModel): diff --git a/src/libtmux_mcp/prompts/recipes.py b/src/libtmux_mcp/prompts/recipes.py index 80dbdbde..84e0650a 100644 --- a/src/libtmux_mcp/prompts/recipes.py +++ b/src/libtmux_mcp/prompts/recipes.py @@ -10,6 +10,58 @@ from __future__ import annotations +import re + +#: A tmux pane id is ``%`` followed by digits and nothing else, so a +#: value that is not one cannot name a pane. +_PANE_ID_RE = re.compile(r"^%\d+$") + +#: Characters a renderer treats as structure rather than as a name. +_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") + + +def _validated_pane_id(value: str) -> str: + """Return *value* if it is a pane id, else refuse. + + Prompt arguments are interpolated into PROSE, not only into the + code blocks, and the free-text arguments are already ``repr``'d + there. The identifier arguments were not -- so a ``pane_id`` + carrying a newline put its own text at the start of a line of + instructions, and repeated it at every mention. The split was + backwards: the arguments that legitimately carry arbitrary content + were escaped, and the ones with a trivial format were not. + + Validating beats escaping here because the format admits nothing to + escape. + """ + if not _PANE_ID_RE.match(value): + msg = ( + f"pane_id must look like '%1' (got {value!r}). Prompt text is " + "rendered as instructions, so a value that is not a pane id " + "would be read as part of them." + ) + raise ValueError(msg) + return value + + +def _validated_session_name(value: str) -> str: + """Return *value* if it can name a session, else refuse. + + Looser than :func:`_validated_pane_id`, because session names are + nearly free-form. The line is drawn at characters that would + restructure the rendered text rather than name anything; tmux + itself refuses ``:`` and ``.``. + """ + if not value or _CONTROL_RE.search(value) or {":", "."} & set(value): + msg = ( + f"session_name may not be empty or contain control characters, " + f"':' or '.' (got {value!r}). tmux rejects the punctuation, and " + "prompt text is rendered as instructions, so a newline would " + "start a line of them." + ) + raise ValueError(msg) + return value + def run_and_wait( command: str, @@ -34,6 +86,7 @@ def run_and_wait( timeout : float Maximum seconds to wait for command completion. Default 60. """ + pane_id = _validated_pane_id(pane_id) multiline = "\n" in command or "\r" in command history_argument = " suppress_history=False,\n" if multiline else "" history_warning = ( @@ -85,6 +138,7 @@ def diagnose_failing_pane(pane_id: str) -> str: pane_id : str The pane to diagnose. """ + pane_id = _validated_pane_id(pane_id) return f"""Something went wrong in tmux pane {pane_id}. Diagnose it: 1. Call `snapshot_pane(pane_id="{pane_id}")` to get content, @@ -119,6 +173,7 @@ def build_dev_workspace( paths. Pass e.g. ``"tail -f /var/log/syslog"`` on Linux or ``"log stream --level info"`` on macOS. """ + session_name = _validated_session_name(session_name) return f"""Set up a 3-pane development workspace named {session_name!r} with editor on top, a shell on the bottom-left, and a logs tail on the bottom-right: @@ -165,6 +220,7 @@ def interrupt_gracefully(pane_id: str) -> str: pane_id : str Target pane. """ + pane_id = _validated_pane_id(pane_id) return f"""Interrupt whatever is running in pane {pane_id} and verify that control returns to the shell: diff --git a/src/libtmux_mcp/resources/hierarchy.py b/src/libtmux_mcp/resources/hierarchy.py index 0dcfd45e..21ad6c4c 100644 --- a/src/libtmux_mcp/resources/hierarchy.py +++ b/src/libtmux_mcp/resources/hierarchy.py @@ -7,21 +7,19 @@ from fastmcp.exceptions import ResourceError -from libtmux_mcp._utils import ( - _get_server, +from libtmux_mcp._serialize import ( _serialize_pane, _serialize_session, _serialize_window, ) +from libtmux_mcp._servers import _get_server, _probe_liveness if t.TYPE_CHECKING: from fastmcp import FastMCP + from libtmux.server import Server -#: MIME type advertised for resources that return structured tmux -#: metadata (session / window / pane views). Previously these resources -#: returned ``json.dumps(...)`` with no MIME annotation, so clients -#: treated the payload as opaque text. Declaring ``application/json`` -#: lets clients parse it automatically. +#: MIME type advertised for resources returning structured tmux metadata, +#: so a client parses the payload instead of treating it as opaque text. _JSON_MIME = "application/json" #: MIME type for the pane-content resource, which returns raw captured @@ -31,6 +29,62 @@ _TEXT_MIME = "text/plain" +def _raise_if_unreachable(server: Server) -> None: + """Refuse to answer for a server that exists but will not talk. + + ``server.sessions`` swallows the failure and yields an empty list, + so a live session-holding server whose socket cannot be queried -- + a client/server protocol mismatch is the realistic cause -- reads + as "no sessions". That is the wrong conclusion rather than a + missing one. + + The tool path already discriminates this; the fix never reached + here, so the two surfaces disagreed about the same server. + """ + alive, reason = _probe_liveness(server) + if not alive and reason is not None: + msg = ( + f"tmux server exists but could not be queried: {reason}. " + "Reporting no sessions here would be wrong rather than empty." + ) + raise ResourceError(msg) + + +def _normalize_pane_id(pane_id: str) -> str: + """Accept ``10`` as well as ``%10`` in a resource URI. + + The URI layer percent-decodes every captured template parameter + (fastmcp ``resources/template.py``), and a tmux pane id starts with + ``%``. ``%10`` therefore arrives as the single byte ``0x10``. + ``%0``-``%9`` survive only because one trailing hex digit is an + INVALID escape and passes through, so the whole surface works right + up until a server has created its eleventh pane. + + Re-encoding the decoded byte is not a fix: ``%80``-``%99`` decode to + bytes that are not valid UTF-8 and arrive as U+FFFD, so the digits + are already gone. That repair passes every test written against low + pane numbers and fails once a pane id reaches 128. + + A bare number needs no escaping at any pane number, so that is the + spelling this accepts. ``%2510`` (a caller who pre-encoded the + percent) still decodes to ``%10`` and keeps working. + """ + if pane_id and not pane_id.startswith("%"): + return f"%{pane_id}" + return pane_id + + +def _raise_pane_not_found(pane_id: str) -> t.NoReturn: + """Report a missing pane, naming a mangled id rather than hiding it.""" + msg = f"Pane not found: {pane_id!r}" + if not pane_id.isprintable(): + msg += ( + " -- the id was percent-decoded by the URI layer. Address the " + "pane by its bare number instead, e.g. 'tmux://panes/10'." + ) + raise ResourceError(msg) + + def register(mcp: FastMCP) -> None: """Register hierarchy resources with the FastMCP instance.""" @@ -53,6 +107,7 @@ def get_sessions(socket_name: str | None = None) -> str: JSON array of session objects (MIME: ``application/json``). """ server = _get_server(socket_name=socket_name) + _raise_if_unreachable(server) sessions = [_serialize_session(s).model_dump() for s in server.sessions] return json.dumps(sessions, indent=2) @@ -67,10 +122,27 @@ def get_session( ) -> str: """Get details of a specific tmux session. + .. warning:: + + Percent-encode the name when building this URI. The path + segment is percent-DECODED before lookup, so a session + literally named ``pct%20name`` is reached only as + ``tmux://sessions/pct%2520name`` — pasting the raw name + reads the session named ``pct name`` instead, silently and + with no error. A space needs no encoding, which is what + makes a literal ``%`` easy to miss. + + A ``/`` in the name needs the same treatment and fails + differently: tmux permits a session named ``a/b``, and + ``tmux://sessions/a/b`` does not match this template at all, + so it is rejected as "Unknown resource" -- which reads as + "no such endpoint" rather than "encode the name". + ``tmux://sessions/a%2Fb`` reaches it. + Parameters ---------- session_name : str - The session name. + The session name, percent-encoded. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. @@ -81,6 +153,7 @@ def get_session( (MIME: ``application/json``). """ server = _get_server(socket_name=socket_name) + _raise_if_unreachable(server) session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" @@ -101,6 +174,10 @@ def get_session_windows( ) -> str: """List all windows in a tmux session. + The session name is percent-decoded before lookup; see + :func:`get_session` for why a name containing ``%`` must be + encoded before it goes into this URI. + Parameters ---------- session_name : str @@ -114,6 +191,7 @@ def get_session_windows( JSON array of window objects (MIME: ``application/json``). """ server = _get_server(socket_name=socket_name) + _raise_if_unreachable(server) session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" @@ -150,6 +228,7 @@ def get_window( (MIME: ``application/json``). """ server = _get_server(socket_name=socket_name) + _raise_if_unreachable(server) session = server.sessions.get(session_name=session_name, default=None) if session is None: msg = f"Session not found: {session_name}" @@ -175,7 +254,9 @@ def get_pane(pane_id: str, socket_name: str | None = None) -> str: Parameters ---------- pane_id : str - The pane ID (e.g. '%1'). + Pane number or id. Prefer the bare number ('10'): + a URI-escaped '%10' is decoded to a control character + before it reaches here. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. @@ -185,10 +266,11 @@ def get_pane(pane_id: str, socket_name: str | None = None) -> str: JSON object of pane details (MIME: ``application/json``). """ server = _get_server(socket_name=socket_name) + _raise_if_unreachable(server) + pane_id = _normalize_pane_id(pane_id) pane = server.panes.get(pane_id=pane_id, default=None) if pane is None: - msg = f"Pane not found: {pane_id}" - raise ResourceError(msg) + _raise_pane_not_found(pane_id) return json.dumps(_serialize_pane(pane).model_dump(), indent=2) @@ -203,7 +285,9 @@ def get_pane_content(pane_id: str, socket_name: str | None = None) -> str: Parameters ---------- pane_id : str - The pane ID (e.g. '%1'). + Pane number or id. Prefer the bare number ('10'): + a URI-escaped '%10' is decoded to a control character + before it reaches here. socket_name : str, optional tmux socket name. Defaults to LIBTMUX_SOCKET env var. @@ -213,10 +297,11 @@ def get_pane_content(pane_id: str, socket_name: str | None = None) -> str: Plain text captured pane content (MIME: ``text/plain``). """ server = _get_server(socket_name=socket_name) + _raise_if_unreachable(server) + pane_id = _normalize_pane_id(pane_id) pane = server.panes.get(pane_id=pane_id, default=None) if pane is None: - msg = f"Pane not found: {pane_id}" - raise ResourceError(msg) + _raise_pane_not_found(pane_id) lines = pane.capture_pane() return "\n".join(lines) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index c5772f1b..0a85906c 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -15,6 +15,8 @@ from fastmcp.server.middleware.timing import TimingMiddleware if t.TYPE_CHECKING: + from collections.abc import Iterable + from libtmux.server import Server from libtmux_mcp.__about__ import __version__ @@ -22,13 +24,13 @@ _configure_history_defaults, _resolve_suppress_history, ) -from libtmux_mcp._utils import ( +from libtmux_mcp._safety import ( TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, VALID_SAFETY_LEVELS, - _server_cache, ) +from libtmux_mcp._servers import _drain_server_cache from libtmux_mcp._wait_policy import ( WAIT_MAX_SECONDS_ENV, _configure_wait_ceiling, @@ -48,40 +50,25 @@ logger = logging.getLogger(__name__) install_fastmcp_validation_log_filter() -#: Cache-key shape used by :data:`_server_cache` and the GC helper. -#: ``(socket_name, socket_path, tmux_bin)`` — see -#: :func:`libtmux_mcp._utils._get_server`. -_ServerCacheKey: t.TypeAlias = tuple[str | None, str | None, str | None] - # --------------------------------------------------------------------------- -# _BASE_INSTRUCTIONS — composed from named segments. -# -# The string handed to FastMCP grew organically from "what does this server -# do?" toward a hybrid of positive guidance (HIERARCHY, READ_TOOLS, -# WAIT_NOT_POLL) and *gap-explainers* (HOOKS_GAP, BUFFERS_GAP) that document -# why a tool the agent might expect is absent. Splitting into named -# constants keeps additions deliberate: when a new ``_GAP`` segment feels -# tempting, prefer first to push the explanation into the relevant tool's -# docstring/description (where the agent encounters it at call time) and -# only fall back to a server-level segment when the gap is *server-shaped* -# (e.g. an entire tool family is intentionally missing). -# -# Tests assert on substrings of ``_BASE_INSTRUCTIONS``, so the join -# shape (segment count, ``"\n\n"`` separator) must stay stable even as -# individual instruction strings evolve. +# _BASE_INSTRUCTIONS — positive guidance plus gap-explainers for tools an +# agent might expect to exist. Before adding a ``_GAP`` segment, push the +# explanation into the relevant tool's docstring, where the agent meets it +# at call time; a server-level segment is for a server-shaped gap, such as +# a whole tool family. Tests assert on substrings, so the join shape +# (segment count, ``"\n\n"`` separator) must stay stable. # --------------------------------------------------------------------------- _INSTR_HIERARCHY = ( "libtmux MCP server for tmux. " "tmux hierarchy: Server > Session > Window > Pane. " - "Prefer pane_id (e.g. '%1') for targeting. " + "Target with pane_id (e.g. '%1'); input tools require one. " "Targeted tmux tools accept socket_name (defaults to LIBTMUX_SOCKET); " "list_servers discovers sockets via TMUX_TMPDIR plus extra_socket_paths." ) -#: Activation rule. Names positive triggers and explicit anti-triggers -#: so bare 'pane'/'window'/'session' default to tmux but the server -#: stays out of the way for browser/editor/GUI/Jupyter contexts. +#: Activation rule: bare 'pane'/'window'/'session' default to tmux, with +#: anti-triggers keeping browser, editor, GUI and Jupyter contexts clear. _INSTR_SCOPE = ( "TRIGGERS: invoke for tmux objects (panes, windows, sessions). " "Bare 'pane', 'split', 'this terminal', 'send keys', 'scrollback', " @@ -108,13 +95,11 @@ "WAIT, DON'T POLL: run_command for authored commands needing " "status; wait_for_channel for custom tmux wait-for; capture_since " "for tailing; wait_for_text for output you don't author " - "(patterns=null=any output; stop=[] bails); " + "(patterns=null=any output; a stop hit returns at once); " "send_keys_batch for raw input." ) -#: Gap-explainer: write-hook tools are intentionally absent. See module -#: comment above for when to add another ``_GAP`` segment vs. push the -#: explanation into a tool description. +#: Gap-explainer: write-hook tools are intentionally absent. _INSTR_HOOKS_GAP = ( "HOOKS ARE READ-ONLY: inspect via show_hooks/show_hook. " "Write hooks survive process death; keep them in your tmux config file." @@ -124,8 +109,7 @@ #: buffers can include OS clipboard history. See module comment above. _INSTR_BUFFERS_GAP = ( "BUFFERS: load_buffer stages, paste_buffer delivers, delete_buffer " - "removes via returned BufferRef. No list_buffers: tmux buffers may include " - "clipboard history." + "removes by BufferRef. No list_buffers: they may hold clipboard history." ) _BASE_INSTRUCTIONS = ( @@ -177,11 +161,9 @@ def _build_instructions( "raw send/batch/paste and spawn do not." ) - # Tier-conditioned discoverability hint. False-positive activation is - # cheap on readonly (worst case: an extra list_panes call) and - # expensive on mutating/destructive (where kill_* is one mis-routed - # query away). Reuse the existing safety axis instead of shipping a - # separate LIBTMUX_DISCOVERABILITY knob. + # Tier-conditioned discoverability hint: a false positive costs an + # extra list_panes on readonly, but on mutating/destructive kill_* is + # one mis-routed query away. if safety_level == TAG_READONLY: parts.append( "\n\nReadonly mode: probe snapshot_pane/list_panes/search_panes if unsure." @@ -192,10 +174,9 @@ def _build_instructions( msg = "required server instructions exceed the 2048-byte MCP budget" raise RuntimeError(msg) - # Agent tmux context is optional. Prefer the complete form, then discard - # the untrusted socket name and explanatory workflow before omitting the - # context entirely. Never byte-slice text because UTF-8 characters may be - # split across bytes. + # Agent tmux context is optional: prefer the complete form, then drop + # the untrusted socket name, then the context entirely. Never + # byte-slice -- a UTF-8 character may split across bytes. tmux_pane = os.environ.get("TMUX_PANE") if tmux_pane: # Parse TMUX env: "/tmp/tmux-1000/default,48188,10" @@ -223,26 +204,12 @@ def _build_instructions( if len(combined.encode("utf-8")) <= _INSTRUCTIONS_MAX_BYTES: return combined - # Past this point the is_caller workflow — the only place an - # agent learns how to answer "which pane am I in?" — cannot fit. - # There are two very different reasons for that, and they need - # opposite handling: - # - # (a) our own _INSTR_* segments grew until the workflow no - # longer fits alongside them. That is a build-time bug in - # this file, and silently dropping the workflow hides it: - # the budget assertions only check the total size, and - # the degraded form still contains "Agent context", so - # nothing fails. Raise and make the author shorten a - # segment. - # - # (b) TMUX_PANE / TMUX are pathologically large. That is - # runtime data we do not control, and refusing to start - # over a hostile environment variable would be a denial - # of service. Degrade, as before. - # - # A nominal pane id discriminates: if the workflow fits with a - # realistic id, only the oversized runtime data pushed us over. + # The is_caller workflow no longer fits. A nominal pane id + # separates the two causes: if it fits with a realistic id, only + # oversized TMUX_PANE/TMUX pushed us over, so degrade rather than + # refuse to start over hostile runtime data. If it does not, our + # own _INSTR_* segments grew -- a build-time bug, and silently + # degrading hides it from the total-size assertions. nominal_context = ( "\n\nAgent context: this MCP runs inside tmux pane %000" ". Tool results mark is_caller=true; filter list_panes " @@ -283,9 +250,8 @@ def _resolve_safety_level(value: str | None) -> str: ) _wait_max_seconds = _resolve_wait_max_seconds(os.environ.get(WAIT_MAX_SECONDS_ENV)) -#: Tools covered by the tail-preserving response limiter. Only tools -#: whose output is terminal scrollback benefit from this backstop; -#: structured responses from list/get tools stay under the cap naturally. +#: Tools whose output is terminal scrollback, so they need the +#: tail-preserving limiter; structured responses stay under the cap. _RESPONSE_LIMITED_TOOLS = [ "capture_pane", "capture_since", @@ -308,9 +274,9 @@ async def _lifespan(_app: FastMCP) -> t.AsyncIterator[None]: Shutdown -------- - Clears the process-wide :data:`_server_cache` so repeated test runs - don't share stale Server references and HTTP-transport reload - cycles start clean. Also best-effort GC's any leftover + Drains the process-wide server cache so repeated test runs don't + share stale Server references and HTTP-transport reload cycles + start clean. Also best-effort GC's any leftover ``libtmux_mcp_*`` paste buffers on every cached server — agents are supposed to ``delete_buffer`` after use, but an interrupted call chain can leak. Note: FastMCP lifespan teardown runs on @@ -325,20 +291,19 @@ async def _lifespan(_app: FastMCP) -> t.AsyncIterator[None]: try: yield finally: - _gc_mcp_buffers(_server_cache) - _server_cache.clear() + _gc_mcp_buffers(_drain_server_cache()) -def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: +def _gc_mcp_buffers(servers: Iterable[Server]) -> None: """Best-effort delete of leaked ``libtmux_mcp_*`` paste buffers. - Iterates every cached tmux Server, lists buffer names, and deletes - anything matching the MCP prefix. Never raises: tmux may be - unreachable, buffers may vanish mid-scan, and none of that should - block lifespan shutdown. Logs at debug level so operators can - still surface leaks via verbose logging. + Lists buffer names on each tmux server and deletes anything matching + the MCP prefix. Never raises: tmux may be unreachable, buffers may + vanish mid-scan, and none of that should block lifespan shutdown. + Logs at debug level so operators can still surface leaks via verbose + logging. """ - for server in cache.values(): + for server in servers: try: result = server.cmd("list-buffers", "-F", "#{buffer_name}") except Exception as err: @@ -362,34 +327,18 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: ), website_url="https://libtmux-mcp.git-pull.com/", lifespan=_lifespan, - # Middleware runs outermost-first. Order rationale: - # 1. TimingMiddleware — neutral observer; start clock as early - # as possible so timing captures middleware cost too. - # 2. TailPreservingResponseLimitingMiddleware — bounds the final - # tool result on the way back out. Tool errors may already be - # ToolResult(is_error=True) here, so truncation preserves that - # flag instead of turning expected failures into schema errors. - # 3. ToolErrorResultMiddleware — converts tool-call failures to - # rich ToolResult(is_error=True) results and transforms - # resource errors to MCP code -32002. Must stay OUTSIDE the - # audit + retry + safety trio: all three depend on exception - # semantics (audit catches to record outcome=error, retry - # matches LibTmuxException via __cause__, and safety's tier - # denials must propagate as exceptions for audit to record - # them), so converting the exception to a result any deeper - # would silently break all three. - # 4. AuditMiddleware — outside SafetyMiddleware so tier-denial - # events (which raise ExpectedToolError before call_next inside - # Safety) are still logged with outcome=error. Without this - # ordering, denied access attempts would silently bypass the - # audit log — a security-observability gap. - # 5. ReadonlyRetryMiddleware — inside Audit so retries are - # audited once each, outside Safety so tier-denied tools - # never reach retry. Only readonly tools are retried; - # mutating/destructive tools pass straight through. - # 6. SafetyMiddleware — innermost gate (fail-closed). Denials - # never reach the tool, but the audit record above captures - # them for forensic review. + # Middleware runs outermost-first, and positions 2-6 are load-bearing: + # 1. Timing — a neutral observer, outermost so the clock covers + # middleware cost too. + # 2. TailPreservingResponseLimiting — truncation preserves an + # is_error result instead of making it a schema error. + # 3. ToolErrorResult — must stay OUTSIDE audit/retry/safety: all + # three read exception semantics, so converting to a result any + # deeper breaks them. + # 4. Audit — outside Safety, or tier denials bypass the audit log. + # 5. ReadonlyRetry — inside Audit so each retry is audited, outside + # Safety so a denied tool never reaches retry. + # 6. Safety — innermost, fail-closed. middleware=[ TimingMiddleware(), TailPreservingResponseLimitingMiddleware( @@ -421,9 +370,7 @@ def _register_all() -> None: register_tools(mcp) _configure_history_defaults(mcp, _suppress_history) - # Publish the resolved wait ceiling to the wait tool module. Same - # shape as the history default above: server owns env resolution, - # tool modules never import server globals. + # Server owns env resolution; tool modules never import server globals. _configure_wait_ceiling(_wait_max_seconds) register_resources(mcp) register_prompts(mcp) @@ -436,8 +383,11 @@ def _enable_allowed_tools() -> None: if _mcp_visibility_configured: return - # Use FastMCP's native visibility system as primary gate, - # with the SafetyMiddleware as a secondary layer for clear error messages. + # The ENFORCEMENT gate: it holds even for a call that skips the + # middleware chain (``call_tool`` accepts ``run_middleware=False``). + # ``SafetyMiddleware`` is the EXPLANATION gate -- disabling makes + # ``get_tool`` answer None, so FastMCP would otherwise report a gated + # tool as ``Unknown tool``. allowed_tags = {TAG_READONLY} if _safety_level in {TAG_MUTATING, TAG_DESTRUCTIVE}: allowed_tags.add(TAG_MUTATING) diff --git a/src/libtmux_mcp/tools/batch_tools.py b/src/libtmux_mcp/tools/batch_tools.py index 2091f4b4..232c060f 100644 --- a/src/libtmux_mcp/tools/batch_tools.py +++ b/src/libtmux_mcp/tools/batch_tools.py @@ -10,14 +10,13 @@ from fastmcp.tools.base import ToolResult from pydantic import BaseModel -from libtmux_mcp._utils import ( +from libtmux_mcp._errors import ExpectedToolError, handle_tool_errors_async +from libtmux_mcp._safety import ( ANNOTATIONS_RO, TAG_DESTRUCTIVE, TAG_MUTATING, TAG_READONLY, TAG_SELF_BOUNDED, - ExpectedToolError, - handle_tool_errors_async, ) from libtmux_mcp.middleware import DEFAULT_RESPONSE_LIMIT_BYTES from libtmux_mcp.models import ( @@ -125,16 +124,19 @@ async def _get_allowed_tool_tier( tool = await fastmcp.get_tool(operation.tool) if tool is None: - msg = f"Unknown tool: {operation.tool!r}" - raise ExpectedToolError(msg) + # None means nonexistent OR disabled by tier, so "Unknown tool" + # here would deny that a gated tool exists. Hand it on: the nested + # call runs with ``run_middleware=True``, letting + # ``SafetyMiddleware`` name the tier while FastMCP still raises + # ``NotFoundError`` for a typo. + return # ``max_tier`` is a CEILING, so a readonly tool is reachable through - # every batch wrapper, not only the readonly one. The batch loop is - # serial with no aggregate deadline and ``MAX_BATCH_OPERATIONS`` is - # 1000, so a self-bounded wait batched N times costs N x its - # ceiling. Reject per-operation (not pre-loop) so the raise becomes - # a ``success=False`` row and ``on_error='continue'`` isolation is - # preserved. + # every batch wrapper. The loop is serial with no aggregate deadline + # and ``MAX_BATCH_OPERATIONS`` is 1000, so a self-bounded wait batched + # N times costs N x its ceiling. + # Rejected per-operation, not pre-loop, so the raise becomes a + # ``success=False`` row and ``on_error='continue'`` still isolates. if TAG_SELF_BOUNDED in tool.tags: msg = ( f"Tool {operation.tool!r} enforces its own wait ceiling and " @@ -259,8 +261,12 @@ async def _call_tools_batch( on_error: _OnError, max_tier: str, ctx: Context | None, + timeout: float | None = None, ) -> ToolCallBatchResult: """Execute nested MCP tool calls serially through FastMCP.""" + if timeout is not None and timeout <= 0: + msg = f"timeout must be positive, or null for no cap (received {timeout})" + raise ExpectedToolError(msg) if not operations: msg = "operations must contain at least one tool call" raise ExpectedToolError(msg) @@ -268,7 +274,7 @@ async def _call_tools_batch( msg = f"operations must contain at most {MAX_BATCH_OPERATIONS} tool calls" raise ExpectedToolError(msg) if on_error not in {"stop", "continue"}: - msg = "on_error must be 'stop' or 'continue'" + msg = f"on_error must be 'stop' or 'continue' (received {on_error!r})" raise ExpectedToolError(msg) if ctx is None: msg = "FastMCP context is required; call this tool through MCP." @@ -276,7 +282,28 @@ async def _call_tools_batch( results: list[ToolCallOperationResult] = [] stopped_at: int | None = None + deadline = time.monotonic() + timeout if timeout is not None else None for index, operation in enumerate(operations): + # BETWEEN operations is enough because the time is genuinely in + # this loop. A thousand mutations take 67 s, and a client giving up + # does not stop the server -- 617 further mutations landed after + # the caller was gone, with no report of where it stopped. + if deadline is not None and time.monotonic() > deadline: + assert timeout is not None + results.append( + ToolCallOperationResult( + index=index, + tool=operation.tool, + success=False, + error=( + f"batch execution exceeded timeout of {timeout}s; " + "operations from this index onward did not run" + ), + elapsed_seconds=0.0, + ) + ) + stopped_at = index + break result = await _call_one_tool( fastmcp=ctx.fastmcp, operation=operation, @@ -304,6 +331,7 @@ async def _call_tools_batch( async def call_readonly_tools_batch( operations: list[ToolCallOperation], on_error: _OnError = "stop", + timeout: float | None = None, ctx: Context | None = None, ) -> ToolCallBatchResult: """Call readonly MCP tools serially and return per-tool results. @@ -313,10 +341,33 @@ async def call_readonly_tools_batch( middleware, and safety checks. Mutating and destructive tools are rejected even if the server process itself is running at a higher safety tier. + + Batching trades one transport round trip for one re-paid per-call + framework cost, so whether it wins depends on which is bigger -- + that is, on **how expensive the nested tool is**, not on how many + of them there are. There is no general break-even count. Measured + over stdio, the ratio at a single operation ranged from 0.71 to + 1.83 across four read tools, so the same n=1 both wins and loses + depending on what is nested. + + Batch when the operations are individually expensive or return a + lot: a mixed read of ``get_pane_info`` + ``list_panes`` + + ``show_option`` + ``capture_pane`` measured 65 ms batched against + 120 ms serial. For the cheapest single-value reads the two are + close at low counts and batching pulls ahead as the count grows. + Per-operation ``elapsed_seconds`` in the result is there so a + caller can settle this for its own mix rather than trusting a + curve. + + ``timeout`` bounds the WHOLE batch, checked between operations. + Without it a batch runs to completion, and the cap is 1000 calls: + a client that gives up does not stop the server, so the work keeps + applying after the caller is gone. """ return await _call_tools_batch( operations=operations, on_error=on_error, + timeout=timeout, max_tier=TAG_READONLY, ctx=ctx, ) @@ -326,6 +377,7 @@ async def call_readonly_tools_batch( async def call_mutating_tools_batch( operations: list[ToolCallOperation], on_error: _OnError = "stop", + timeout: float | None = None, ctx: Context | None = None, ) -> ToolCallBatchResult: """Call readonly or mutating MCP tools serially and return per-tool results. @@ -333,10 +385,16 @@ async def call_mutating_tools_batch( Use for ordered tmux workflows where every step is still an existing typed MCP tool. Destructive tools are rejected regardless of the process-wide safety tier. + + ``timeout`` bounds the WHOLE batch, checked between operations. + Without it a batch runs to completion, and the cap is 1000 calls: + a client that gives up does not stop the server, so the work keeps + applying after the caller is gone. """ return await _call_tools_batch( operations=operations, on_error=on_error, + timeout=timeout, max_tier=TAG_MUTATING, ctx=ctx, ) @@ -346,6 +404,7 @@ async def call_mutating_tools_batch( async def call_destructive_tools_batch( operations: list[ToolCallOperation], on_error: _OnError = "stop", + timeout: float | None = None, ctx: Context | None = None, ) -> ToolCallBatchResult: """Call readonly, mutating, or destructive MCP tools serially. @@ -353,10 +412,16 @@ async def call_destructive_tools_batch( This wrapper preserves the normal per-tool schemas and middleware but its tier permits destructive nested operations. Prefer the narrower readonly or mutating wrappers whenever possible. + + ``timeout`` bounds the WHOLE batch, checked between operations. + Without it a batch runs to completion, and the cap is 1000 calls: + a client that gives up does not stop the server, so the work keeps + applying after the caller is gone. """ return await _call_tools_batch( operations=operations, on_error=on_error, + timeout=timeout, max_tier=TAG_DESTRUCTIVE, ctx=ctx, ) diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 6663d7e7..7fe55d8a 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -34,23 +34,23 @@ import typing as t import uuid -from libtmux_mcp._utils import ( +from libtmux_mcp._bounded_io import ( + CAPTURE_DEFAULT_MAX_LINES, + _truncate_lines_tail, +) +from libtmux_mcp._errors import ExpectedToolError, handle_tool_errors +from libtmux_mcp._exec import _LIVENESS_TIMEOUT_SECONDS, _tmux_argv +from libtmux_mcp._guards import _raise_if_untargeted +from libtmux_mcp._resolve import _resolve_pane +from libtmux_mcp._safety import ( ANNOTATIONS_MUTATING, ANNOTATIONS_RO, ANNOTATIONS_SHELL, TAG_MUTATING, TAG_READONLY, - ExpectedToolError, - _get_server, - _resolve_pane, - _tmux_argv, - handle_tool_errors, ) +from libtmux_mcp._servers import _get_server from libtmux_mcp.models import BufferContent, BufferRef -from libtmux_mcp.tools.pane_tools.io import ( - CAPTURE_DEFAULT_MAX_LINES, - _truncate_lines_tail, -) #: Default line cap for :func:`~libtmux_mcp.tools.buffer_tools.show_buffer`. #: Reuses the scrollback default so agents see one consistent bound across @@ -59,6 +59,7 @@ if t.TYPE_CHECKING: from fastmcp import FastMCP + from libtmux.server import Server #: Reserved prefix for MCP-allocated buffers. Anything matching this #: regex is considered agent-owned; anything else is the human user's @@ -96,11 +97,11 @@ def _validate_logical_name(name: str) -> str: >>> _validate_logical_name("has space") Traceback (most recent call last): ... - libtmux_mcp._utils.ExpectedToolError: Invalid logical buffer name: 'has space' + libtmux_mcp._errors.ExpectedToolError: Invalid logical buffer name: 'has space' >>> _validate_logical_name("with/slash") Traceback (most recent call last): ... - libtmux_mcp._utils.ExpectedToolError: Invalid logical buffer name: 'with/slash' + libtmux_mcp._errors.ExpectedToolError: Invalid logical buffer name: 'with/slash' """ if name == "": return "buf" @@ -125,15 +126,29 @@ def _validate_buffer_name(name: str) -> str: >>> _validate_buffer_name("clipboard") Traceback (most recent call last): ... - libtmux_mcp._utils.ExpectedToolError: Invalid buffer name: 'clipboard' + libtmux_mcp._errors.ExpectedToolError: 'clipboard' is not an MCP-allocated buffer >>> _validate_buffer_name("libtmux_mcp_shortuuid_buf") Traceback (most recent call last): ... - libtmux_mcp._utils.ExpectedToolError: Invalid buffer name: 'libtmux_mcp_...' + libtmux_mcp._errors.ExpectedToolError: 'libtmux_mcp_...' is not an MCP-... """ if not _BUFFER_NAME_RE.fullmatch(name): - msg = f"Invalid buffer name: {name!r}" - raise ExpectedToolError(msg) + # Not "invalid": tmux accepts any of these names happily. It is + # this server that only touches buffers it allocated, because + # tmux buffers can hold OS clipboard history and a tool that + # reads arbitrary ones is a clipboard reader. + msg = f"{name!r} is not an MCP-allocated buffer" + raise ExpectedToolError( + msg, + suggestion=( + "This server only reads and writes buffers it created " + "(libtmux_mcp_<32-hex>_