Fix silent-failure and misleading-error classes across the tool surface - #125
Open
tony wants to merge 73 commits into
Open
Fix silent-failure and misleading-error classes across the tool surface#125tony wants to merge 73 commits into
tony wants to merge 73 commits into
Conversation
why: Reviewing a branch across the agent CLIs meant checking it out, swapping to the checkout, then remembering to unwind both. uv resolves a git ref on its own, so a pull request can be swapped in without a working copy at all — which makes reverting the ordinary config restore, with nothing left on disk to prune. Resolution happens when an agent starts the server, so a bad ref would otherwise land in every config and fail opaquely inside each one. The swap now proves the command answers MCP before writing anything. what: - Add `use-local --pr N`, writing `uvx --from <remote>@refs/pull/N/head` - Complete an MCP initialize round trip before the first write, with `--no-preflight` to skip it - Read the pull request through `gh` to confirm it exists and label the output, keeping resolution independent of it - Recognize the shape in `status`, ahead of the version-pin branch that would otherwise report the ref as a pin
why: The JSON writer re-serialized the whole document to change one entry, so it escaped every non-ASCII character in the file and appended a trailing newline the file may never have had. In ~/.claude.json that reached model labels and prompt history the swap never read, turning a one-entry edit into a diff spanning the file — noise a reviewer has to read past in `--dry-run`, and a rewrite of bytes that were not ours to touch. Dropping the escaping alone would trade one defect for a worse one: a lone surrogate, which is what a JavaScript writer emits for a string sliced through a surrogate pair, has no UTF-8 encoding, and the resulting UnicodeEncodeError is not the RuntimeError the per-CLI handler catches — it would abort the whole run. what: - Write non-ASCII literally, falling back to an escaped document for the one input that cannot be encoded - Carry the source file's trailing-newline convention across the rewrite, requiring the original bytes rather than defaulting them - Assert an unmodified config round-trips byte-identical across the shapes the agent CLIs write
why: The module docstring described use-local as rewriting configs to run a local checkout, which is now only half of what it does. A reader meeting the file for the first time would not learn --pr exists. what: - Name the pull-request form alongside the checkout form - Add it to the examples block
why: The summary line named the repo's checkout as the only outcome, so it read as false for the branch immediately below it. what: - State both targets, and which flag selects the second
why: The subcommand list is where someone discovers what use-local is for, and it named only the checkout. what: - Name the pull-request target in the subparser help line
why: The note claimed all six CLIs emit JSON.stringify output. Two of them, codex and grok, are TOML and never reach this writer at all. what: - Say JSON CLIs, which is the set the note is about
why: The branch carried two entries whose prose explained mechanism — worktree pruning, the initialize round trip, escape encoding — none of which a reader needs to decide whether the change matters to them. what: - Collapse them into one entry naming what the tool can now do and what it no longer does to a config
why: The per-CLI handler caught only RuntimeError, so a config that would not parse escaped as a traceback and took the whole run with it — the other CLIs never got their swap. The comment above it already claimed a clean per-CLI error, and doctor already caught the wider set. what: - Catch ValueError and OSError alongside RuntimeError in status and use-local, matching what doctor already does - Cover malformed JSON, a truncated document, and invalid UTF-8, and that one bad config does not stop the CLIs behind it
why: load_state parsed the file with a bare json.loads, so a truncated or hand-edited one raised through every command that reads it — revert and doctor included. Its own docstring already promised a hand-edited file could not crash the script. Returning empty silently would be its own trap: it means the record of every swap is gone, so revert would report nothing to unwind while swapped configs and their backups sit on disk. Naming the file is what lets someone go find those backups. what: - Degrade to no entries when the file will not parse, or holds a shape that carries none, and say so on stderr
why: The backup write sat between the two guarded blocks, so an unwritable config directory raised a PermissionError through the whole run and the CLIs behind it never got their swap. Aborting that CLI is the right half of the trade rather than swapping anyway: the backup is the only copy of the pre-swap config, so a swap that could not take one would leave nothing to revert to. what: - Catch the failure, name it per CLI, and move on to the next
why: --pr took any int, so a typo built a ref like refs/pull/-5/head and carried it as far as the preflight. Pull requests are numbered from one, so a non-positive value can only be a mistake. what: - Parse --pr through a validator that requires a positive number, matching how --env already reports a malformed argument
why: atomic_write staged beside and replaced the config path. A config symlink into a dotfiles checkout was therefore destroyed while its target stayed stale. what: - Resolve symlinks before staging so rename stays atomic at the target - Cover link chains and swap/revert recovery with sandboxed tests
why: Concurrent swaps and partial filesystem failures could orphan the pristine backup, lose recovery state, or restore through a repointed symlink. what: - Serialize mutations and write recovery state before config changes - Restore the original target while preserving file modes - Keep recovery material on failure and return nonzero when incomplete - Add adversarial coverage for races and filesystem failures
why: The unreleased note should summarize the branch's complete user-visible result without exposing implementation detail. what: - Lead with checkout-free pull-request testing and preflight - Summarize configuration preservation and recovery guarantees
`use-local --pr N` points every installed agent CLI at a pull request without creating a checkout and validates the MCP server before changing configuration. Configuration updates preserve unrelated text, file permissions, and symlink targets. Atomic recovery retains the original backup and state through concurrent or failed swaps, while incomplete recovery returns nonzero.
Freeze the mcp_swap and ruff work from the unreleased section into the dated 0.1.0a20 entry, add its lead paragraph, and open a fresh 0.1.x unreleased placeholder above it. Bump the package version 0.1.0a19 -> 0.1.0a20 across pyproject.toml and __about__.py, and refresh uv.lock. No tool behavior changes here, so the section carries only Documentation and Development entries. MIGRATION is untouched: it has no unreleased heading to retitle, and this release documents no breaking change.
why: Three things vary per CLI -- the file format, the key path to the server map, and the shape of one entry -- but only the format was recorded on CLIInfo. The other two were spelled as `cli in (...)` membership tuples repeated across get_server, set_server, delete_server and _all_server_specs. Two of those four dispatches end in a bare `else` that falls through to the TOML `mcp_servers` key, so a CLI registered in CLIS but forgotten in one tuple reports "no entry" instead of failing; the other two raise AssertionError, which the caller's (RuntimeError, ValueError, OSError) handler does not catch. what: - Add `container` (key path to the server map) and `dialect` (entry shape) to CLIInfo, both required so a new CLI cannot be added without deciding them - Replace the four membership dispatches with one `_server_map()` accessor that walks the key path and creates intermediates on demand - Extend the non-mapping guard Claude already had to every CLI: a container key holding something other than a table now raises RuntimeError naming the path, rather than a TypeError out of setdefault - Rename `to_json_dict(include_stdio_type=)` to `to_entry_dict(dialect)` and move the TOML table build behind `_as_toml_table()`, so the two writers no longer duplicate the entry shape No behavior change for the six registered CLIs; the existing 123 mcp_swap tests pass unmodified apart from the fixture gaining the two new required fields.
why: A config format the script cannot round-trip is one it must not write. tomlkit gives TOML a format-preserving round trip; JSON goes through stdlib json.dumps, which reserializes the whole document. For a JSONC file that is doubly wrong -- json.loads rejects `//` outright, and anything that did parse would come back stripped of every comment. The obvious dependency was measured and rejected. json-five round-trips comments via its model API, but it raises on the valid JSON string "C:\\x" and silently decodes the six literal characters \u0041 to "A". stdlib json reads both correctly. A parser that quietly rewrites a value nobody touched is the exact failure this script is built to prevent, so it is not worth a PEP 723 line. what: - Parse JSONC by blanking comments and trailing commas in place -- offsets preserved -- then handing the result to stdlib json, so escape semantics are the standard library's rather than a reimplementation's - Apply writes as text splices located by a string-aware scanner, one splice at a time with a rescan between, so every byte outside a replaced value survives untouched. Same technique opencode's own writer uses through jsonc-parser's modify() - Render short scalar arrays inline so a swapped `command` stays on one line instead of exploding a dotfiles-tracked config into a large diff - Dispatch dump_config_bytes on the exact format instead of `!= "json"`, which would have sent a third format to the TOML writer and put TOML bytes in a JSON file Verified byte-identical round trips for line and block comments, trailing commas, absent final newline, non-ASCII, `//` inside a URL, `/*` inside a string, Windows paths and a literal \u escape. No CLI uses fmt="jsonc" yet; the codec lands ahead of its first consumer.
why: opencode is the seventh agent CLI on this machine and the first
whose config differs from the others in all three axes at once: the file
is JSONC, the server map hangs off `mcp` rather than `mcpServers`, and
one entry packs argv into a single `command` array with its environment
table spelled `environment`. Getting any of that wrong is not a soft
failure -- a scalar `command` is a decode error that stops opencode from
starting at all, and an `env` key is dropped without a word.
what:
- Register opencode: binary `opencode`, `$XDG_CONFIG_HOME/opencode/
opencode.jsonc` (honouring XDG the way opencode's own loader does),
fmt jsonc, container ("mcp",), dialect opencode
- Add the opencode dialect to both directions: written as
{"type": "local", "command": [argv...]} with "environment", and read
back by splitting the array into the portable command/args pair
- Seed "$schema" when creating an entry in a config that was empty;
opencode writes that line itself on first load, so writing it here
avoids a second edit landing right after the swap
- Derive the detect column width from the longest registered name
instead of a hardcoded 7, which "opencode" overflows
Splitting the array on read is what makes `is_local_uv_directory`,
`local_repo_path` and `pr_ref` keep working, and those are what the
"already local -- no change" check depends on. Without it every run
would rewrite a config that was already correct.
Verified end to end against a sandboxed HOME/XDG_CONFIG_HOME: add,
replace, revert byte-identical, second-run idempotence, a comment living
inside the replaced entry, an existing `environment` table, an empty
file, a symlinked config, --pr, and status reading each shape back.
why: pi is the eighth agent CLI here, and the only one that ships no MCP
client. Its README says "No MCP" outright, the released 0.84.1 build
contains no MCP code, and its Settings interface has no key that could
hold a server. MCP reaches pi only through the third-party
`pi-mcp-adapter` extension, which reads ~/.pi/agent/mcp.json in the
Claude-Desktop `mcpServers` schema.
That leaves one honest way to support pi. This script's value rests on
`status` telling the truth about what an agent will actually run, so
writing a file pi ignores and reporting success would cost more than not
supporting pi at all. Registering the path and naming the missing
prerequisite keeps both: the swap lands where the adapter looks, and
`detect` says why it will not take effect yet.
what:
- Register pi: binary `pi`, ~/.pi/agent/mcp.json, fmt json,
container ("mcpServers",), standard dialect -- no new dialect needed,
the adapter speaks the same shape cursor and gemini do
- `detect` appends "needs the pi-mcp-adapter package; pi has no built-in
MCP client" whenever that package is absent from
~/.pi/agent/npm/node_modules
Verified end to end against a sandboxed HOME: detect's caveat, add,
status, and revert byte-identical, with an unrelated server left alone.
why: The two new CLIs introduce axes nothing in the suite exercised: a JSONC config, a container key that is neither mcpServers nor mcp_servers, an entry that packs argv into one array, and a config read by an extension rather than by the agent. The JSONC writer also makes a stronger promise than the JSON one -- it splices text, so it owes byte fidelity rather than only value fidelity, and that has to be asserted on bytes. what: - test_fake_home_covers_every_registered_cli: the fixture replaces CLIS wholesale, so a CLI missing from it raises KeyError out of half a dozen unrelated doctor tests. Names the invariant once - Registration and set/get/delete round-trips for both CLIs, which is what proves each name reached all four container branches - opencode dialect both directions: argv packed into one array, env written as "environment", and the array split back into command+args so is_local_uv_directory, local_repo_path and pr_ref keep working - Comment fidelity: line, block and trailing comments, a comment living inside the entry being replaced, sibling servers, symlinked config, $schema seeding, and a second swap reporting no change - PRESERVED_JSONC byte-identical round-trips, including `//` inside a URL, `/*` inside a string, a Windows path and a literal \u escape -- the cases that make a naive comment-stripper corrupt a value - A parity test asserting JSONC values match stdlib json wherever stdlib can parse the body at all Verified these fail for the right reason: disabling the JSONC writer so jsonc falls through to the plain JSON one turns 8 of them red, the comment and byte-fidelity ones included.
why: Eight places enumerate the agent CLIs, and they had already drifted apart before this branch -- scripts/README.md claimed four CLIs when six were supported, and its extension guide named three per-CLI branch sites when there were four. Adding two more CLIs without reconciling them leaves the docs describing a script that no longer exists. what: - Module docstring: line 6 is the argparse description, so it no longer tries to list every CLI by name. The Scope section gains the two new config paths, opencode's three-sibling-global-files caveat, and pi's missing MCP client - scripts/README.md: the CLI table now lists all eight with their formats, and the extension guide describes CLIInfo's fmt/container/ dialect fields instead of branch sites that no longer exist. Adds the ALL_CLIS warning -- a CLI missing from it has its state dropped on load, so revert forgets the swap - docs install widget: an opencode panel. `opencode mcp add tmux -- <cmd>` is non-interactive given a name and a `--` command, so it is a CLI panel; that also avoids its array-command shape, which the shared JSON body cannot express. _cli_body falls through to codex by default, so the branch is explicit - Skill and cli-matrix: opencode added to the skill's CLI list and both new CLIs described from source. Their matrix row reads "not yet verified" rather than guessing -- that file's value is that every cell was empirically confirmed, and neither has been driven through the harness - justfile: the mcp-detect comment listed four CLIs; it now names none - CHANGES: entries under Development for the swap-script work, and under Documentation for the install-widget panel pi is deliberately absent from the install widget and has no matrix row: it cannot consume MCP, so there is nothing for a user to install into.
why: CI runs `uv run mypy .`, which covers scripts/; the chain in AGENTS.md is `uv run mypy src tests`, which does not. The opencode work was typed against the narrower invocation and broke the build. what: - Annotate the opencode entry dict, which lost its `dict[str, t.Any]` when the dialect branch was added and was then inferred narrowly enough that assigning `environment` failed - Overload `_server_map` on `create`, matching `_claude_project_node` and `_claude_user_servers`, so a create=True call is not Optional at the call site - Annotate its cursor so the walk returns a mapping rather than Any `just mypy` type-checks every .py file and would have caught this; `uv run mypy src tests` is the invocation that does not.
why: The insertion branch asks whether an object already has content by looking at the comment-blanked text, where a comment is indistinguishable from whitespace. An object holding only a comment therefore looked empty, and the insert spliced over the whole interior and took the comment with it -- silently, in a file the user wrote by hand. what: Measure the interior in the original text and anchor the splice after what it actually holds. A genuinely empty interior rstrips to nothing and the anchor collapses to the old splice point, so every previously working insert is byte-identical. Covers the same splice at the document root, where there is no enclosing member, and adds the comment-only object to the byte-fidelity cases.
why: Removing a member spliced from the end of the previous member to past the following comma, so a member between two others took the comma on both sides and left its neighbours undelimited. The next merge pass then raised JSONDecodeError, which the caller catches as a bad config, so the swap reported opencode unreadable and skipped it. Reachable without doing anything unusual: an entry carrying `enabled` or `timeout` -- both valid opencode fields the swap does not write -- hits it. what: Take exactly one delimiter with the member. Every member but the first takes the comma before it; the first takes the comma after. Read that comma out of the blanked text, so a comma inside a comment is not mistaken for the separator and a real one behind a comment is still found. Chosen over two larger alternatives after both were built and measured: across 5,508 generated documents this and a helper-based rewrite emitted identical bytes, and a third approach that also preserved the deleted member's comment corrupted files -- it stripped the newline terminating a `//` comment, pulling the closing brace inside it. A comment sitting above a removed member is still removed with it. That is unchanged, and settling it means first deciding whether such a comment documents the member or the object; re-parenting it onto the next member would leave a false statement in the user's file.
why: pi's MCP file is read by pi-mcp-adapter, which parses it through strip-json-comments with trailing commas allowed. Registering it as fmt="json" sent it to strict json.loads, so a config the adapter reads without complaint came back as a JSONDecodeError and status and use-local reported pi unreadable and skipped it. The .json suffix is misleading; the format the reader accepts is JSONC. what: fmt="jsonc". The container key and entry dialect are unchanged -- the adapter speaks the same Claude-Desktop mcpServers shape cursor and gemini do. Comments and a trailing comma now survive a swap as well.
why: The panel offered Project alongside User and named `./opencode.json` as its destination, but emitted the same command for both. `opencode mcp add` resolves its target with resolveConfigPath(Global.Path.config, true) on the non-interactive path, so it writes the global file whichever scope was picked. A reader following the Project panel would register the server for every project while believing it was scoped to one repo. what: opencode offers User only. The prose that pointed at `opencode mcp add` for workspace precedence is corrected in the same pass -- that command cannot reach a project file; editing `$PWD/opencode.json` by hand can.
The CLI table and the scope note still called it JSON, which is what the suffix says and not what the adapter reading it accepts.
why: The insertion path built the member with an f-string, so the key went in raw while every value went through json.dumps. `--server` takes an arbitrary string: give it one holding a backslash, a quote, or a newline and the emitted text does not parse back. The member is then never found on the next pass, so the merge re-inserts it until the pass ceiling -- burning CPU for over an hour while holding the exclusive swap lock, then failing with "JSONC merge did not converge". what: Render the key with json.dumps, honouring the same ensure_ascii the values use. Found by exercising the flag surface rather than the config surface; the config-shape matrix passes either way because a derived server name never contains one of these characters.
`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 was not checked by anything — the loop only entered its
branch when one was present, and bound the field to `_field`, the name
that means "deliberately unused".
libtmux's `QueryList` resolves a 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 real one. That is
the worst shape in this class: not an error the agent can react to, but
a confident wrong answer shaped like a right one.
Field names are now checked against the object being filtered, with
near-misses suggested, mirroring the operator error that was already
good. Validation covers only the leading segment, so nested traversal
like `active_window__window_name__contains` keeps working — the check
rejects what the type cannot have rather than whitelisting.
The type is a parameter rather than read off the first item, so an
empty list still validates. That is exactly when a typo most needs
reporting.
A pane whose current directory contains a newline makes libtmux fail to parse `-F` output, and because every pane lookup enumerates panes, the whole 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 could not repair it through the MCP either: every tool that could have moved the pane out needed the same enumeration. It is now an expected failure that names the cause, says the blast radius is server-wide rather than one pane, and gives the command that locates the offender. Matched on the message because the raise site is a stdlib `zip` with no dedicated exception type. The parse itself is fixed upstream in tmux-python/libtmux#752, but this diagnosis is kept rather than deferred: the floor is `libtmux>=0.62.0` and the installed version is not this package's to choose. `exc.PaneNotFound` prefixes its own message and the mapper prefixed it again, so the most frequently hit error in the server read `Pane not found: Pane not found: %9999`.
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". Two of four calls answered with a confident falsehood; `list_panes` did error. `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 `sessions` docstring points at `is_alive` to tell the two apart, but it cannot — they collapse to the same `False`. tmux distinguishes them on stderr, so `_probe_liveness` reads that instead of the boolean. `list_sessions` probes only when the listing came back empty: a server that listed anything cannot be unreachable, so the common path keeps its single round trip on the most-called discovery tool. `ServerInfo` gains `unreachable_reason`. When set, `is_alive=False` means "could not ask", not "not running", and `session_count=0` carries no information. `list_servers`' socket scan shares the probe. The trigger is an ordinary tmux upgrade — sockets outlive the binary that made them. Verified against a real 3.2a client and a 3.7c server. Also: `exit_copy_mode` on a pane that was not in a mode returned a full `PaneInfo`, reading as confirmation the pane had left copy mode, while tmux said `not in a mode` and exited 1. `Pane.send_keys(copy_mode_cmd=...)` discards that result the same way the ordinary send path did. Both copy-mode call sites now share a helper that raises with tmux's stderr; no `--` is needed there because every command is a module constant rather than caller text. The liveness tests drive a crafted result rather than a second tmux binary, so they assert the discrimination without depending on which tmux a CI job installed.
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 — so this is a diagnostic, not a behavior change. The rationale is the one already written above `matched_at_entry`'s own entry scan: an agent must be able to tell "already there" from "never arrived". That reasoning was applied to the success patterns and not to the stop patterns three lines away. Also corrects the server instructions. `stop=[] bails` was parsed by two independent readers as "the empty list bails". `stop=[]` is accepted and behaves like `stop=null`; it is a stop *hit* that returns immediately.
The directory scan held each socket's full path in the entry it was iterating 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 tying 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, which also survives symlinks and relative paths. A socket whose name does not round-trip through `tmux -L` — one holding a newline — raised inside `get_server_info` and was dropped from the listing by a bare `continue`, so a live server vanished without a word. It now falls back to the path probe, which always works.
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 its 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 fingerprint degenerates to a single hash whenever the anchor was the last row — 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 far below the real anchor. Everything above it was dropped as "already seen". Matches are now rejected on position past `anchor_abs` — tmux evicts only from the top, so a surviving anchor can only move earlier. That is necessary but not sufficient: an anchor taken at the bottom of an already-saturated history occupies the same row as the current prompt, and the two candidates genuinely overlap. So a single-hash fingerprint on a saturated history 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 itself survived — and panes away from their limit are untouched. Saturation is asked via the existing trim-risk heuristic, not `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 records that "the flood alone is not deterministic — tmux 3.6 retains enough of the original prompt that `_find_unique_cursor_match` re-anchors on the surviving hash" and adds a `clear-history` to force anchor destruction, so the flood-only path that real agents hit was never covered. It is now, without help. Measured before/after on a 20-line history: a 5-line burst returned 0 lines claiming nothing was missed, and now returns the visible content flagged `lines_missed=True`; a 50000-line history is unchanged.
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 so — but it was gated on the caller passing `scope="server"` explicitly. The natural call, `show_hooks()`, leaves `scope` at its default of `None` 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 server instructions point agents straight at that pair. The test named for this behavior only exercised the explicit-scope path, so the default one — the one agents actually take — was never covered.
A capture large enough to hit the 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 and worse
than having no limiter. `capture_pane`'s docstring advertises
`max_lines=None` for a complete capture, so the documented way to ask
for everything was the way to break it. 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
was already fixed, and this class's own docstring spells out why —
"MCP clients then validate the truncated text against the tool's output
schema and fail with a transport-level error". The successful-response
half was left standing with the same consequence and no `is_error` to
restore.
Truncated successes now carry structured content. Only the
`{"result": str}` shape fastmcp gives a `-> str` tool is rebuildable;
model- and list-shaped payloads carry the oversize inside their own
fields and cannot be trimmed from the flattened text, so those return
an actionable tool error telling the agent to narrow its range rather
than a response its client will reject outright.
Verified against a real >1 MB capture: `max_lines=None` returns 954 KB
of truncated data with structured content intact instead of raising.
Tail preservation, the dropped-line count, and the per-tool caps were
correct throughout; only the over-cap rebuild was broken.
`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 and is what led two readers to
expect it.
The result now carries `searched_scope`, 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. Telling the agent
what was searched is strictly more useful than a slow complete answer,
because it also reveals the knob.
Also: `paste_text("")` errored with `no buffer libtmux_mcp_..._paste`
because tmux creates no buffer for empty content — an error for a
no-op, naming an internal buffer the caller never chose. It is now a
no-op success.
And `send_keys` documents the roughly 16 KB tmux ceiling that surfaces
as `command too long`, pointing at `paste_text` for larger payloads,
plus a warning against verifying a write by string-comparing captured
text: tmux renders combining marks and zero-width joiners as `<XXXX>`
placeholders, so `école` and emoji come back transformed even when the
bytes were delivered correctly.
`show_option(option="history-limit", scope="session")` returned `value: null` while 50000 was in force. 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` reads as "not set at THIS scope" rather than "not set anywhere". `show_environment` no longer encodes removal in the key. tmux prints a removed variable as `-NAME`; the dash was kept and the value set to boolean `true`, so `variables["KRB5CCNAME"]` raised `KeyError` while `variables["-KRB5CCNAME"]` answered `true` — the inverse of the truth, for a variable that is explicitly unset — and every consumer had to type-check a `str | bool` mapping. `variables` now holds only names that are set, mapped to their values, with removed names listed separately. `search_panes` rejects `offset < 0` and `limit < 1` instead of clamping or answering with an empty page. `limit=0` returned `matches: []`, which an agent cannot tell from a genuine miss, and a negative offset was clamped to zero while being echoed back unchanged. Also records the measured false-positive band for the capture_since saturation guard: on a 50000-line limit it reports a loss from roughly 92% full, and is clean at 0/10/50/80/88%. Growth in `history_size` would narrow it, but a burst that saturates midway both grows and evicts, so trusting growth would reopen the silent loss that guard closes.
`run_command` assumed a cooperative shell sitting at a prompt, and neither verified nor documented it. That assumption holds in tests and breaks in a live session, in two ways. A full-screen program owns the pane's keyboard, so the exit-status wrapper is consumed as ITS keystrokes. Measured against `less`: `s=$?...` became less's save-to-file command, a fragment escaped to a shell, and the pane was left on less's help screen. In `vi` the same payload lands in the buffer, where `:`-prefixed fragments are commands that edit and write files. `alternate_on` was readable before the call the whole time — `snapshot_pane` already reports it — so the tool now reads it and refuses, naming the occupant and pointing at `send_keys`. The guard is deliberately narrow. There is no reliable "pane is busy" signal: `pane_current_command` is the foreground process, which is legitimately non-shell for the entire duration of any long command an agent wants to run, so refusing on that would break the main use. `alternate_on` means a program has taken the whole grid, which is never a state where a shell wrapper makes sense. A timeout does not cancel the command. The keystrokes are already in the pane's input buffer, so a blocked shell runs them whenever it next reads a line — verified: a command reporting `timed_out=True` executed once the blocking `sleep` returned. An agent reading `timed_out` alone concludes it did not run and retries, which is how a `git push` or a migration runs twice, the second time unwatched. The result now carries `command_may_still_run`, and the docstring states both preconditions. The wrapper is sent through the checked send path, so a rejected send surfaces as an error rather than as a timeout. Also: `Invalid buffer name: 'yanked'` misattributed. tmux accepts that name; it is this server that only touches buffers it allocated, because tmux buffers can hold OS clipboard history. The message says so, and states that a copy-mode yank or tmux's own `buffer0` is unreachable by design rather than leaving that to be inferred.
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 intercepts `tools/call` only, while the fastmcp 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. The property is "an expected failure is never an internal error", and it has to hold on every path that transform serves, not just the one that was noticed. An error this server raises deliberately to describe a caller-caused failure now maps to `-32002` on a resource read and `-32602` elsewhere, with its own message intact. The predicate is a named tuple rather than `ExpectedToolError` alone: the resource handlers raise fastmcp's `ResourceError`, so a check on our own type matched nothing and looked like it had worked. A test pins the negative control — a genuine `RuntimeError` still reads as `-32603 Internal error:` — so this reclassifies caller mistakes without hiding server faults. Also documents that a resource URI's path segment is percent-decoded before lookup. With sessions named `pct name` and `pct%20name` both live, `tmux://sessions/pct%20name` returns the former: the wrong session, silently. Only `pct%2520name` reaches the latter. A space needs no encoding, which is what hides it. 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 models is the better fix and is left as follow-up rather than landed unverified.
Four tools returned success for an operation that did not do what the
caller asked. In each 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 there would then read back as
if it were live capture. The destination is 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, and only `0` some
200 ms later. Reading it here would be a check that never fires.
`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 entire
server while the tool returned a `PaneInfo` for a pane that no longer
existed. The program is checked before respawning, because catching it
afterwards can only report the loss and even that races the dying
process.
`respawn_pane` returning the process it replaced. `pane_pid` changes at
once but `pane_current_command` lags it by ~14 ms, so the model
described the process about to be replaced. Not cosmetic:
`test_respawn_pane_replaces_shell` asserts on that field and has been
flaking since it was written, absorbed by `--reruns=2`.
Three predicates were measured for that wait and all three fail. Pid
change is necessary but not sufficient (stale 15/15 runs). Command
change never fires when a pane is respawned as itself. Two consecutive
equal reads is the worst: the pre-change value is itself stable, so a
fast poll debounces onto the OLD value and returns it confidently —
0/6 stale at a 20 ms poll, 2/6 at 5 ms, 3/6 at 1 ms, correct only by
accident of the interval. What ships matches the requested command's
basename and is interval-independent. A commandless respawn needs no
wait at all, structurally: `spawn.c` guards its default-command
fallback on `sc->argc == 0 && (~sc->flags & SPAWN_RESPAWN)`, so a
respawn skips it and reuses the pane's existing argv.
`paste_text` with a trailing newline. Bracketed paste — the default —
holds the newline in the shell's edit buffer instead of submitting.
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, out of order with the
call. The result says so and names both ways to submit.
`test_capture_pane_truncates_tail_preserving` failed intermittently in full-suite runs and passed in isolation, which reads as a draw race and is not one. It borrowed the shared pane fixture, and an earlier test splitting that window left the pane a few rows tall; `capture_pane` then returned fewer lines than `max_lines`, nothing was truncated, and the header assertion failed. Measured: 24 rows gives 24 captured lines and a header, 3 rows gives 2 lines and none. Deterministic given the height — the line count lands on 2, not on 5 or 7, which is what rules out a partially drawn pane, since a draw race would give the right count with wrong content. So this one is a test-isolation bug rather than a product defect, the opposite conclusion to the respawn flake fixed alongside it, which was a real defect hiding behind `--reruns=2`. Both were reached by measuring rather than by guessing, and guessing would have got them wrong in opposite directions. The measurement is in the docstring so the next reader does not re-diagnose it as timing.
…reen `alternate_on` 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. It owns the keyboard with `alternate_on=0`, so the exit-status wrapper was typed into it and reported a clean `exit_status=0` — the exact outcome the guard exists to prevent, with no timeout and no `command_may_still_run` to soften it. Confirmed by answering the prompt: pressing `y` flips `alternate_on` to 1. The general test — "the foreground command is not the process tmux started" — was implemented and measured across five pane states, which it separated correctly, and then rejected: this suite refused it because a pane running `bash` inside a `zsh` pane has a perfectly good prompt, as do `sudo -s`, `ssh` and `nix-shell`. "The foreground process isn't the one tmux started" is true and does not answer "is there a prompt". So a small deny-list of pagers and editors instead, where typing a shell wrapper is destructive rather than merely wrong. Incomplete by construction, and the cost is measured rather than assumed: against a `python` REPL the call is not refused, `timed_out` and `command_may_still_run` both fire correctly, and the REPL is left at a `...` continuation where the next input from anyone is swallowed. State survives, nothing is destroyed. Destructive programs are refused; merely disruptive ones are carried by `command_may_still_run`.
`test_capture_pane_truncates_tail_preserving` was moved to a dedicated window on the theory that a shared pane had been shrunk by an earlier test's split. That mechanism is impossible: libtmux's `server` and `session` fixtures are function-scoped, measured as two tests landing on different sockets with both panes at 24 rows, so no earlier test can resize a later one's pane. A 3-row pane does reproduce the symptom, which is what made it look like the cause. Demonstrating that a hypothesis CAN produce a symptom is not evidence that it did, and the check that would have refuted it — reading the fixture scope — was one `rg` away and never run. Reverted rather than left in as a fix that fixes nothing. The correction stays in the test's docstring and CHANGES alongside the two other candidates measured away: the retry budget needs 0.24 s against 2 s even under eightfold load, and randomized ordering survives in isolation. What is actually happening is suite-wide. Run the suite as CI does, `pytest -n auto`, with `--reruns` off, and every run fails — two independent sets of runs give 1/5/2/3/3 and 6/4/1 failures, a different sample each time, well over a dozen distinct tests in the union. Most drive a real `zsh` and wait inside a budget tuned on an idle machine. The family spans at least `tests/test_pane_tools.py` and `tests/test_history.py`, so it is not one module's problem. The same suite in the default configuration reports `968 passed, 6 skipped, 7 rerun`. Three configurations are each blind to this — fixed-order with reruns on, random-order with reruns off but serial, and CI's parallel with reruns on — which is why three separate per-test explanations were proposed and measured away before the shape was visible. Fixing the budgets is a larger piece of work than this branch should absorb; naming the property, with the measurements, is not.
Eleven sampled runs of the parallel suite put all but one failure in `tests/test_pane_tools.py`, which invites narrowing the follow-up to that module. Counting the at-risk pattern instead — drives a real shell AND waits on output — finds it across several modules, with `test_pane_tools.py` holding 78-90% of the wait sites depending on how they are counted. Its dominance in every sample is a density effect, not exclusivity, and a thin tail elsewhere is exactly what the single observed failure in `tests/test_history.py` represents. Sampling was reading a distribution's mode as its support; a density count settles in one pass what eleven runs could not, and points the fix at the right scope.
Recorded as 78-90%; independently recomputed at 77-79% across three patterns, and 75% counting `retry_until` alone. The range is now 75-85%, which covers every measure taken. The spread has a cause worth keeping. A pattern that counts `wait_for_text` calls inflates `test_pane_tools.py`, because `wait_for_text` is the tool UNDER TEST in that module — 110 occurrences of it there are the subject of the assertions rather than waits supporting them. The metric measured something adjacent to the claim in exactly the place the adjacency was greatest, which is the same shape as the signal errors catalogued elsewhere in this branch. `retry_until` alone is the cleaner proxy. The claim being scoped — dominant but not exclusive, so scope the fix by the pattern rather than the filename — survives all of them, which is why it is worth stating and no single figure is.
list_panes(filters={"is_caller": true}) raised "Unknown filter field
'is_caller'". That is the workflow the server instructions document as
the only answer to "which pane am I in?" -- there is no whoami tool.
Filters were pushed straight into libtmux's QueryList, which resolves
keys by getattr on the tmux object. Fields the server computes while
serializing (is_caller) and fields tmux exposes under another name
(window_count, pane_count, active_pane_id) were therefore unreachable.
The error's advice made it worse: "call this tool without filters to
see available fields" points at a listing showing 6 of a Session's 230
filterable fields, two of which were themselves invalid as filters.
Computed fields now filter after serialization through the same
QueryList operators; aliased ones rewrite to their attribute path.
"true" is accepted alongside true because filters is dict[str, str].
The error names the output fields and counts the attributes behind them.
top owns the keyboard but repaints the primary screen, so alternate_on stays 0 for as long as it runs -- measured 0 through 8 seconds, not a startup race. It passed both arms of the busy-pane guard and took the 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. Reported by the QA instance driving the server as a black-box client.
All three found by driving the server as a client against a6af1eb. Operators that cannot apply to a bool are refused. is_caller__in and is_caller__nin both returned zero rows against the same set, and is_caller__regex=".*" matched nothing: libtmux's lookup_in, lookup_nin and lookup_regex guard on isinstance(data, (str, list)) and fall off the end to `return False` for a bool. Not fixable from here, so the operator is checked against the field's declared type instead. An unparseable bool is refused. is_caller="ture" compared a string to True and reported no error -- the typo bug fixed for field names, moved one position right. A traversal path with no trailing operator now works. active_pane__pane_id raised "Invalid filter operator 'pane_id'" because rsplit took the last segment as an operator unconditionally, so the server was stricter than QueryList underneath it and rejected the key form its own alias generates. That fallback would have reintroduced silent-empty one line below the fix: session_name__containss reads as a path, resolves on nothing and filters everything out. A multi-segment path no item can resolve is now an error, and the operator hint is folded into it.
The suite reported green while 2-5 tests failed per parallel run, absorbed by --reruns=2. Six runs of `-n auto --reruns 0` failed 6/6. The dominant cause is not a budget. Twelve helpers slept and claimed to synchronise: _emit_after_baseline's docstring said "once the wait has armed" over `await asyncio.sleep(0.2)`, and five more said "let wait_for_text capture its baseline first" over `sleep(0.1)`. Losing that race is permanent, not slow: 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. One such failure burned a 20 second budget, which is why raising timeouts was the wrong first instinct. Those sites now await an event set on the wait's second _bounded_capture. The first IS the entry capture; the second is issued only after its rows are stored, so arming is complete by then. The risk-band test resisted that and was restructured: it now fills history into the band before the wait starts instead of racing a 200-line burst against a 2 s budget. Genuine budgets were raised as a class. Every retry_until in the suite is raises=True, and for a positive wait a larger budget costs nothing when the test passes -- it only lengthens time-to-fail. So all 56 sites under 10 s went to 10 s at once, and success-asserting timeout=5.0 sites to 20 s, rather than one per sampled run. test_run_command_reports_unclamped_timeout is the one exception and says so: it asserts the timeout is echoed back verbatim. send_keys_batch's fixture was a third thing: its sends are monkeypatched to return instantly, so 0.05 s bounded only Python overhead and pre-empted the injected TimeoutExpired the test exists to exercise. Measured, same box: before 6/6 runs failing (3,2,2,1,1,5) at loadavg 13-33; after 1/3 (0,2,0) at loadavg 25-45. The remaining two were the same budget shape at a timeout=5.0 the first sweep missed.
set_environment(name="-u", value="VICTIM") returned status="set" and 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. set_option(option="-g", value="x") let tmux eat -g as the global flag, leaving `x` as the option name, which tmux prefix-matched to xterm-keys and turned off -- so a caller who named neither xterm-keys nor anything like it silently changed it. tmux parses flags before quoting helps, and libtmux emits [name, value] with no `--`. Quoting itself is sound: `A;kill-server` and `CANARY_TWO -u` are stored verbatim as literal names, killing nothing. The hole is a FIRST character of `-` that names a flag the target command accepts; `-U` was already refused as an invalid flag, which is what makes -u and -g the dangerous cases rather than the obvious ones. Environment names are validated against POSIX [A-Za-z_][A-Za-z0-9_]*; option names must not begin with `-`, the looser rule @user-options need. display_message takes the third form -- a format may legitimately start with `-`, so it gets tmux's `--` terminator instead of a refusal. format_string="-p" was previously eaten as tmux's print flag and tmux answered with its DEFAULT message. Adds unset_environment. value="" sets an empty string rather than removing, and EnvironmentResult.removed described a state no tool could produce, so the only route to an unset was the bug above: closing it without this would have removed a capability, not a hazard. Reported by the QA instance driving the server as a black-box client.
capture_since re-delivered already-seen scrollback whenever a pane got
NARROWER, reporting lines_missed=false. Narrowing rewraps history
longer, the cursor carried no width, so `start` went negative by
exactly the rewrap growth and capture-pane -S returned that many stale
rows as new. Measured: 160->40 columns re-delivered 189 lines, and one
column re-delivered three.
Widening was already caught, which hid it: rewrap makes history shorter
and the existing shrink branch fires. The cursor now carries pane_width
and a change invalidates it as pane_pid does -- width is the precise
discriminator, since history growing from real output must stay valid.
A cursor minted before the field existed is invalidated rather than
trusted, and self-heals on the next call.
run_command now refuses a pane in copy/view/clock mode. Those own the
keyboard while alternate_on stays 0 and pane_current_command is still
the shell, so both existing arms missed it. Measured with a client
attached: over eight trials the command never ran and the scroll
position was destroyed in seven, while the result claimed
command_may_still_run. #{pane_in_mode} rides in the display-message
round trip the guard already makes.
Reported by the QA instance driving the server as a black-box client.
Raising a wait budget is free only when the call returns as soon as its condition holds. Three sites are the opposite: they spend the whole budget by design, to prove something does NOT happen. The earlier sweep raised those too, turning a ceiling into a bill. wait_for_text [silent_pane_still_times_out] 20.0s -> 1.0s pipe_pane "the file must not grow" 10.0s -> 1.0s wait_for_text tail cap (pattern never fires) 6.0s -> 3.0s The pipe_pane one is the shape worth naming: a retry_until(raises=True) INSIDE pytest.raises(WaitTimeout). It reads as a positive wait and behaves as a negative one, which is why the sweep's classifier missed it. The parametrized entry-row test now derives its budget from the case: only the cases that must match get headroom. Serial suite 139s -> 117s; slowest single test 20.0s -> 3.0s.
tmux://panes/%10 returned "Pane not found: " for every pane numbered 10 or above. A pane id starts with `%`, and the URI layer percent-decodes every captured template parameter, so `%10` arrived as byte 0x10. `%0`-`%9` survived only because one trailing hex digit is an INVALID escape and passes through. The resource surface therefore worked until a server created its eleventh pane, while the tool surface answered correctly for the same pane. The obvious repair is wrong and the comment says why: re-encoding the decoded byte handles %10-%7f and cannot handle %80-%99, which decode to non-UTF-8 bytes and arrive as U+FFFD with the digits gone. It would pass every test written against low pane numbers and fail at pane 128. A bare number needs no escaping at any pane number, so tmux://panes/10 now works; %2510 keeps working. The not-found error repr()s the id and names the encoding when it is unprintable -- it previously read "Pane not found: " with an invisible control character. Reported by the QA instance driving the server as a black-box client.
list_servers probed each live socket with its own tmux subprocess, serially, and is strictly linear in live servers at ~12ms each. A box with 119 of them spent 1.5 SECONDS in the tool the server instructions name for discovery -- the first call a client makes. Every other readonly tool measures 12-58ms. The probes are independent and 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 bought only 2x, and the reason was elsewhere: _get_server held the module cache lock across Server.is_alive(), a tmux subprocess round trip, so every concurrent tool call in the process queued behind one another for a subprocess duration. The liveness check moves outside the lock, and threads racing to cache a key agree via setdefault. The second fix is not about list_servers. Any concurrent use was serialised there: a readonly batch, two agents on one server, a wait in one pane while another is queried. Measured at 40 live servers: 536ms serial, 176ms at 4 workers, 80ms at 16, 76ms at 32. Stale entries were never the cost and still are not -- 200 extra non-socket files add ~8ms. Reported by the QA instance benchmarking the readonly tool surface.
Fixture finalizers do not run when pytest is SIGKILLed, so an interrupted run left a live tmux daemon and its socket behind permanently. Measured on a development box: 119 live libtmux_test* servers spanning three days. A clean run leaks none -- verified before/after, delta zero -- so this is specifically about interrupted runs, which a session chasing flaky tests produces plenty of. Not only untidy: list_servers probes every live socket, so the debris is what made that tool spectacular rather than merely slow. 119 servers x ~12.7ms is the 1517ms measured there. After reaping, list_servers on the real socket directory drops from 1517ms to 61ms. pytest_sessionstart reaps libtmux_test* sockets older than an hour. The age gate is load-bearing: pytest-xdist workers all start at once, so an unconditional reaper in one worker would kill servers another worker just created.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug-hunting pass over the MCP tool surface. A second Claude instance drove the server as a black-box MCP client — twelve phases, including a 9-version tmux matrix run twice — while this one reproduced and fixed what it found. Every finding was reproduced independently before a fix landed, and every fix was re-tested by the black-box instance against a worktree of the pushed branch.
26 fixes across 17 commits. Each commit passes the full chain (
ruff check,ruff format,uv run mypy .,pytest --reruns 0,just build-docs), and CI is green across tmux 3.2a, 3.3a, 3.4, 3.5, 3.6 and master.The pattern
Nearly every high-severity finding is one defect in a different module: an operation failed, or could not be answered, and the server returned something success-shaped that was not true.
Unknown tool: 'kill_pane'— it exists and is gatedsend_keyswith a-prefixKeys sent to pane %N— nothing was sent[]— indistinguishable from "nothing matched"ValueError: invalid literal for int()is_alive=False,list_sessions=[], no errorhistory-limitlines_missed=Falseshow_hooks()Internal error: Session not found: …pipe_paneto an unwritable pathPiping pane %N to …— no file, everrespawn_panewith a bad shellpaste_textwith a trailing newlineText pasted— the command had not runThe error path was less defended than the happy path. The
ExpectedToolError+suggestion+meta.error_typemachinery here is good; it was simply not reached by the cases that mattered most.Three observations shaped how the fixes were written:
Not crashing is not the same as being right. Making the pane-state
int()calls tolerant would have removed the crash and kept the lie —pane_deadis itself blanked for a vanished pane, so a killed pane would then have been reported as respawned.When a fix targets a code path rather than a property, the property stays broken wherever the path forks.
ToolErrorResultMiddlewarestripsInternal error:ontools/call; the transform it inherits serves every message kind, so resources kept reporting caller mistakes as server faults. Fixed at the fork, not the path. A subsequent audit of the three multi-site defect classes found one previously-unexaminedget_tool()caller (benign) and confirmed the rest.Several tests asserted the property but never on the path users take, which is how 921 passing tests coexisted with these. There was no
_parse_pane_statetest at all; the hook test only used an explicitscope="server"; the response limiter'sis_errorbranch was fixed and documented while its success twin was left; and thecapture_sinceflood test documents the defect in its own docstring — "the flood alone is not deterministic …_find_unique_cursor_matchre-anchors on the surviving hash" — then adds aclear-historyto force the failure it wanted. Each was written from the fix's point of view rather than the caller's.Fixed
disable()makesget_tool()answerNone, so the middleware'sif tool and not allowedguard never ran for the tools it was written for. The message also hardcodedLIBTMUX_SAFETY=destructivefor every denial. Same defect in the batch wrapper. Denials reach the audit log as denials again;on_call_toolfails closed with no FastMCP context.send_keysno longer drops text starting with-—--terminates flag parsing, a failed send raises with tmux's stderr, and all three call sites share one argv builder.is_alive()collapses "no daemon" and "cannot speak to this server" into the sameFalse; tmux distinguishes them on stderr.ServerInfogainsunreachable_reason.capture_sincereports the loss when a pane laps its history limit. The fingerprint degenerates to a single hash whenever the anchor is the shell prompt — the normal case for an agent tailing an idle pane — and the uniqueness guard asks whether a candidate is unique in the current buffer, not in time.show_hooks()merges both hook trees on the default scope.structured_contentalongsideis_error.run_commandrequires a shell at a prompt — it refuses a pane on the alternate screen rather than typing its wrapper intoless/vi— and reportscommand_may_still_run, because a timeout does not cancel: the keystrokes run whenever the shell next reads a line.alternate_onalone proved necessary-but-not-sufficient —lessviewing apipe_panecapture prompts "may be a binary file" before entering the alternate screen — so a small deny-list of pagers and editors covers that, with its residual gap measured and documented.pipe_panechecks its destination before piping.#{pane_pipe}looks like the discriminator and is not — measured, it reads1immediately after a doomed pipe and only0some 200 ms later.respawn_panechecks the program before respawning, and returns the new process rather than the one it replaced.paste_textsays when a bracketed trailing newline was not submitted, and is a no-op for empty text.search_panesreportssearched_scopeand rejectsoffset < 0/limit < 1;show_optiongainsinclude_inherited;show_environmentseparates removed names from set ones;list_serversrows carry a complete identity;wait_for_textreports a stop marker already on screen; a rejected copy-mode command is no longer success; a newline in a directory name is diagnosed;Pane not found:is no longer said twice; the buffer-name error no longer calls a valid tmux name invalid.Upstream
The newline-in-a-path parse bug is rooted in libtmux and fixed in tmux-python/libtmux#752. It cannot ship here until libtmux releases and this floor moves, so this branch carries the diagnosis instead.
Separately, libtmux's
session_check_name()rejects empty names,.and:, while tmux has no window equivalent. The two halves have different standing and should not be relaxed together:new-session -d -s ''— accepted on 3.2a, rejected on 3.3a, 3.4, 3.5, 3.6 and 3.7, accepted again from 3.7a. On six of nine supported versions libtmux's rejection matches tmux.Where a fix was rejected, and why
Three plausible fixes were measured and discarded rather than shipped, each of which passed its own first test:
capture_sincefingerprint by position alone. Sound, and it fixed one repro — but on a saturated history the anchor's old row and the current prompt's row are the same number, so position cannot discriminate. Needed a second predicate.pipe_paneverifying#{pane_pipe}after piping. Passed the success case; reading it after a doomed pipe returns1, because the shell has been spawned and has not yet failed. A check that could never fire.respawn_panewaiting for two consecutive equal command reads. The pre-change value is itself stable for ~14 ms, so a fast poll debounces onto the OLD value and returns it confidently: 0/6 stale at a 20 ms poll, 2/6 at 5 ms, 3/6 at 1 ms — correct only by accident of the interval. Two other predicates (pid-change, command-change) fail too. What ships matches the requested command's basename and is interval-independent.The suite reports green in CI while two to five tests fail per parallel run. Run it as CI does —
pytest -n auto— but with--rerunsoff, and every run fails. Two independent measurement sets: 1/5/2/3/3 failures across five runs, and 6/4/1 across three. A different sample each time, well over a dozen distinct tests in the union, spanning at leasttests/test_pane_tools.pyandtests/test_history.py. Most drive a realzshand wait inside a budget tuned on an idle machine. The same suite in the default configuration reports968 passed, 6 skipped, **7 rerun**— the rerun count is the only visible trace.This supersedes three separate per-test explanations proposed during this branch, all measured away: a shared pane shrunk by another test's split (impossible — libtmux's
server/sessionfixtures are function-scoped, measured as two tests on different sockets with both panes at 24 rows), a retry budget too tight (the marker needs 0.24 s under eightfold load against 2 s), and test ordering (survives randomization in isolation). One change made on the strength of the first theory was reverted rather than left in as a fix that fixes nothing.Three configurations are each blind to this — fixed-order with reruns on, random-order with reruns off but serial, and CI's parallel with reruns on — which is why a single failing test kept reading as a local defect. Fixing the budgets is larger than this branch should absorb; the measurements are recorded so whoever takes it starts from data. Scope that work by the pattern — drives a real shell and waits on output — not by the filename: sampled runs put nearly everything in
tests/test_pane_tools.py, but counting the pattern finds it across several modules with that one holding roughly 75-85% of the wait sites. Its dominance in every sample is density, not exclusivity. (The spread is instructive too: a pattern countingwait_for_textcalls inflates that module, becausewait_for_textis the tool under test there — the metric measures something adjacent to the claim exactly where the adjacency is greatest.retry_untilalone is the cleaner proxy.)_reports_status_after_shell_state_change[errexit_false]at 3-of-5 is the closest to deterministic.It also matters beyond this suite:
--reruns=2is the mechanism that concealed a genuinerespawn_panedefect found in this same branch, which had been flaking since the day its test was written.Confirmed, reproduced, not fixed here
capture_since's fingerprint could be extended with rows above the anchor, removing the conservative branch above. Needs a cursor version bump; deliberately deferred.start_directoryis silently ignored — tmux falls back to the default directory, and every relative path in that pane then resolves against the wrong root. Genuine design question: tmux's fallback is deliberate, so "fail" and "report the divergence" are different products.pct nameandpct%20nameboth live,tmux://sessions/pct%20namesilently returns the wrong one. Documented; a ready-madeurifield on the models is the real fix.show_optionon an unset user option changes contract at tmux 3.3a.run_command.outputis the whole visible pane, not the command's output.pipe_paneaccepts onlyoutput_path, so tmux's "filter a live stream" use is unrepresentable.move_window(destination_index=)is typedstr. Note for whoever adds agt/ltfilter operator: over string-typed fields a naive comparison makes'9' > '10'true.tmux_binis process-env only whilesocket_nameis per-call, so a server whose binary is not onPATHcannot be reached.capture_pane/capture_since/run_command.mutatingtier can create sessions, windows and panes but reap none of them. Changing this changes what a safety tier permits, so it wants an explicit decision.Worth protecting under refactor
Called out independently by the black-box instance:
capture_since's cursor carries identity, position, content-anchor and geometry, so respawn, resize, cross-pane and corruption are each structurally detectable; the wait ceiling reportseffective_timeoutinstead of clamping silently; batch tools isolate per-row errors, refuse to nest, refuse to batch self-bounded waits, and enforce tier before execution (verified by side effect, not by message);search_panespushes the match into tmux's own#{C:}and its fast and slow paths agree on every probe, with format-string injection inert; audit redaction digests payloads to length + SHA-256 prefix on every path including both nested ones; thesuppress_historycontract holds exactly as documented, verified against real histfiles; stale-socket filtering uses anAF_UNIXconnect rather than spawning tmux per socket; the tool surface is byte-identical across all 9 tmux versions.Notes for contributors
LIBTMUX_TMUX_BINto pin the binary per server, and pointingPYTHONPATHat a worktree of the branch under test, are what made a 9-version matrix tractable. Without a pinned build most of a QA pass is spent deciding whether a result is real — a developer's MCP client config runs the working tree, and a long-lived server freezes it at process start.Two regression tests here were themselves version-fragile before being rewritten. The first
send_keystest asserted on pane contents and failed on 3.2a and 3.4 while passing on 3.6, because it depended on the shell echoing un-submitted text; it now asserts at the argv boundary. The liveness tests drive a crafted result rather than a second tmux binary.