Skip to content

Fix silent-failure and misleading-error classes across the tool surface - #125

Open
tony wants to merge 73 commits into
mainfrom
mcp-improvements
Open

Fix silent-failure and misleading-error classes across the tool surface#125
tony wants to merge 73 commits into
mainfrom
mcp-improvements

Conversation

@tony

@tony tony commented Aug 25, 2026

Copy link
Copy Markdown
Member

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.

Symptom What the agent was told
Off-tier tool call Unknown tool: 'kill_pane' — it exists and is gated
send_keys with a - prefix Keys sent to pane %N — nothing was sent
Typo'd filter field [] — indistinguishable from "nothing matched"
Pane dies mid-wait ValueError: invalid literal for int()
MCP tmux older than the server's is_alive=False, list_sessions=[], no error
Output laps history-limit no lines, lines_missed=False
show_hooks() an incomplete list, nothing marking it incomplete
Response over 1 MB a transport error and no data at all
Missing resource Internal error: Session not found: …
pipe_pane to an unwritable path Piping pane %N to … — no file, ever
respawn_pane with a bad shell success — and the pane, window, session and server destroyed
paste_text with a trailing newline Text pasted — the command had not run

The error path was less defended than the happy path. The ExpectedToolError + suggestion + meta.error_type machinery 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_dead is 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. ToolErrorResultMiddleware strips Internal error: on tools/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-unexamined get_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_state test at all; the hook test only used an explicit scope="server"; the response limiter's is_error branch was fixed and documented while its success twin was left; and the capture_since flood test documents the defect in its own docstring — "the flood alone is not deterministic … _find_unique_cursor_match re-anchors on the surviving hash" — then adds a clear-history to force the failure it wanted. Each was written from the fix's point of view rather than the caller's.

Fixed

  • A gated tool says which tier it needs. disable() makes get_tool() answer None, so the middleware's if tool and not allowed guard never ran for the tools it was written for. The message also hardcoded LIBTMUX_SAFETY=destructive for every denial. Same defect in the batch wrapper. Denials reach the audit log as denials again; on_call_tool fails closed with no FastMCP context.
  • send_keys no 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.
  • A pane that dies mid-wait reports the death, not a parse crash and not a phantom respawn.
  • A typo in a filter field is an error, with near-misses suggested; nested traversal still works.
  • A live server is distinguished from an absent one. is_alive() collapses "no daemon" and "cannot speak to this server" into the same False; tmux distinguishes them on stderr. ServerInfo gains unreachable_reason.
  • capture_since reports 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.
  • An oversized response is truncated, not rejected — the rebuild dropped structured_content alongside is_error.
  • A missing resource is no longer an internal error, fixed at the transform so it holds for every message kind.
  • run_command requires a shell at a prompt — it refuses a pane on the alternate screen rather than typing its wrapper into less/vi — and reports command_may_still_run, because a timeout does not cancel: the keystrokes run whenever the shell next reads a line. alternate_on alone proved necessary-but-not-sufficient — less viewing a pipe_pane capture 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_pane checks its destination before piping. #{pane_pipe} looks like the discriminator and is not — measured, it reads 1 immediately after a doomed pipe and only 0 some 200 ms later.
  • respawn_pane checks the program before respawning, and returns the new process rather than the one it replaced.
  • paste_text says when a bracketed trailing newline was not submitted, and is a no-op for empty text.
  • search_panes reports searched_scope and rejects offset < 0 / limit < 1; show_option gains include_inherited; show_environment separates removed names from set ones; list_servers rows carry a complete identity; wait_for_text reports 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:

  • Dots and colons: tmux accepts them on every supported version; libtmux is stricter.
  • Empty names: tmux flipped twice. Measured with 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_since fingerprint 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_pane verifying #{pane_pipe} after piping. Passed the success case; reading it after a doomed pipe returns 1, because the shell has been spawned and has not yet failed. A check that could never fire.
  • respawn_pane waiting 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 --reruns off, 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 least tests/test_pane_tools.py and tests/test_history.py. Most drive a real zsh and wait inside a budget tuned on an idle machine. The same suite in the default configuration reports 968 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/session fixtures 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 counting wait_for_text calls inflates that module, because wait_for_text is the tool under test there — the metric measures something adjacent to the claim exactly where the adjacency is greatest. retry_until alone 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=2 is the mechanism that concealed a genuine respawn_pane defect 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.
  • A nonexistent start_directory is 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.
  • A resource URI percent-decodes its path segment, so with sessions pct name and pct%20name both live, tmux://sessions/pct%20name silently returns the wrong one. Documented; a ready-made uri field on the models is the real fix.
  • show_option on an unset user option changes contract at tmux 3.3a.
  • run_command.output is the whole visible pane, not the command's output.
  • pipe_pane accepts only output_path, so tmux's "filter a live stream" use is unrepresentable.
  • Numeric tmux fields land as strings in several models, and move_window(destination_index=) is typed str. Note for whoever adds a gt/lt filter operator: over string-typed fields a naive comparison makes '9' > '10' true.
  • tmux_bin is process-env only while socket_name is per-call, so a server whose binary is not on PATH cannot be reached.
  • Truncation is reported three different ways across capture_pane / capture_since / run_command.
  • The mutating tier 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 reports effective_timeout instead 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_panes pushes 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; the suppress_history contract holds exactly as documented, verified against real histfiles; stale-socket filtering uses an AF_UNIX connect rather than spawning tmux per socket; the tool surface is byte-identical across all 9 tmux versions.

Notes for contributors

LIBTMUX_TMUX_BIN to pin the binary per server, and pointing PYTHONPATH at 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_keys test 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.

tony added 30 commits August 9, 2026 07:45
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.
@tony
tony force-pushed the mcp-improvements branch from 9d6f7be to 0325f14 Compare August 25, 2026 01:09
tony added 27 commits August 24, 2026 20:16
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants