Adopt libtmux Pane.capture_since() for the capture_since tool - #122
Draft
tony wants to merge 38 commits into
Draft
Adopt libtmux Pane.capture_since() for the capture_since tool#122tony wants to merge 38 commits into
tony wants to merge 38 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.
why: opencode's config path was taken from $XDG_CONFIG_HOME verbatim. A relative value resolves against the working directory, so the swap read one file when run from one directory and another from elsewhere, and the backup path recorded in the state file was relative too. Revert from any other directory then reported the backup missing, and because a missing backup leaves its state entry in place, that CLI stayed wedged. what: Fall back to ~/.config unless the variable is absolute, which is what the XDG spec requires -- absolute or ignored.
`use-local`, `status`, `revert`, `doctor` and `detect` now reach two more agent CLIs. opencode is the first config the script edits that is neither plain JSON nor TOML: `$XDG_CONFIG_HOME/opencode/opencode.jsonc` is JSONC, its server map hangs off a top-level `mcp` key, and one entry packs argv into a single `command` array with its environment table spelled `environment`. A scalar `command` there is a decode error that stops opencode starting, and an `env` key is dropped without a word. pi ships no MCP client of its own — its README says so outright and the released build contains no MCP code. `~/.pi/agent/mcp.json` is read by the third-party `pi-mcp-adapter`, so `detect` names the missing prerequisite rather than reporting a swap that cannot take effect. - **JSONC:** values come from stdlib `json` after comments and trailing commas are blanked in place, and writes are applied as text splices, so bytes outside a replaced value survive — including a comment written directly above the `command` it explains. No new dependency: the candidate that round-trips comments silently decodes a literal `A` to `"A"`. - **Per-CLI dispatch:** `CLIInfo` gained `container` and `dialect`, retiring the four `cli in (...)` membership tuples. Two of them ended in a bare `else` that fell through to the TOML key, so a CLI registered but missing from one tuple reported "no entry" instead of failing. - **Docs:** an opencode panel in the install picker, the CLI table and the extension guide in `scripts/README.md`, and changelog entries under the 0.1.x unreleased block.
why: Code block guidance drifted into four variants across repos. what: - Merge the code block and shell command sections - Lead with the paste-and-run contract
why: The cursor machinery behind this tool is general-purpose tmux observation, not an MCP concern, and libtmux now ships it as Pane.capture_since(). Keeping a second copy here means two implementations of the same anchor arithmetic drifting apart. what: - Call Pane.capture_since() instead of the local read driver; drop the cursor codec, anchor-loss and trim-risk checks, fingerprint re-anchoring, and the stable double-read - Keep max_lines/max_bytes truncation, which bounds an agent response and is not a tmux concern - Map libtmux's CaptureCursorError family to an agent-facing error advising a fresh cursor, ahead of the generic tmux-error catch-all - Drop state.py readers that only this tool used; wait.py keeps the formats and parser it issues through its own bounded subprocess - Pin libtmux to the branch adding capture_since, temporarily The cursor wire format is unchanged, so the tool's tests pass without modification and previously issued cursors still decode. libtmux PR: tmux-python/libtmux#741
9 tasks
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #122 +/- ##
==========================================
+ Coverage 86.24% 87.43% +1.19%
==========================================
Files 46 46
Lines 4042 3860 -182
Branches 599 567 -32
==========================================
- Hits 3486 3375 -111
+ Misses 404 351 -53
+ Partials 152 134 -18 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
why: Moving the cursor to libtmux changed these from bare ExpectedToolError into chained LibTmuxException subclasses, and the retry middleware decides by walking __cause__. A malformed, cross-pane, dead-pane, or respawned-pane cursor therefore started costing a backoff window and a second tmux round-trip before failing identically. what: - List CaptureCursorError in NON_RETRYABLE_EXCEPTIONS, covering both InvalidCaptureCursor and PaneLifecycleChanged - Extend the deterministic-failure parametrization to both, verified to fail without the entry - Re-pin libtmux to the branch tip
why: Keep CI resolving the branch commit the tool is developed against.
why: The off-loop test injected delay by patching Pane.capture_pane. libtmux's capture_since now issues capture-pane through Pane.cmd directly, so the patch stopped intercepting anything and the test measured an instant call rather than a blocking one. what: - Slow Pane.cmd, which every tmux round-trip in a capture passes through, so the delay cannot be bypassed by a wrapper change - Re-pin libtmux to the branch tip
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.
Important
Not mergeable yet. This branch pins libtmux to an unreleased branch. See Before merging below.
Summary
capture_sincetool's local read driver with libtmux'sPane.capture_since()— the cursor codec, anchor-loss and trim-risk checks, fingerprint re-anchoring, and the stable double-read all move upstream.max_lines/max_bytestruncation here. Bounding a response so one observation cannot blow an agent's context window is an MCP concern, not a tmux one.CaptureCursorErrorfamily to an agent-facing error that advises taking a fresh cursor, ahead of the generic tmux-error catch-all.state.pyreaders only this tool used.wait.pykeeps the formats and parser it issues through its own timeout-bounded subprocess.Pane.capture_since(), temporarily.The motivation is duplication: this tool's cursor machinery is general-purpose tmux observation that libtmux now ships. Two copies of the same anchor arithmetic is two copies to keep correct.
The cursor wire format is unchanged, so this tool's tests pass without modification and cursors issued by earlier versions still decode.
Changes by area
Tool
tools/pane_tools/capture_since.py: CallsPane.capture_since()off the event loop and applies the response limits to what comes back. The module docstring now states which half of the problem lives where.tools/pane_tools/state.py: Retains_PaneState, the format constants, the parser, and the lifecycle guard thatwait.pyimports; the two libtmux-backed readers go with the tool that used them._utils.py: ACaptureCursorErrorbranch in the shared exception mapper.Tests
Behavioral tests are untouched. Two docstrings that explained why a test is shaped the way it is now name the upstream mechanism rather than a private symbol that used to be local.
Design decisions
Truncation stays here. libtmux's
capture_sincereturns unbounded lines, matchingcapture_pane. The limiter runs after the capture completes, and the cursor is built from pane state rather than from the truncated rows, so bounding a response cannot shift where the next observation resumes.CaptureCursorErrorgets its own mapping rather than falling through. The generic branch would render a replayed-cursor mistake astmux error: ..., pointing the agent at tmux when the thing to fix is the cursor it sent. The new branch carries a suggestion to start a fresh observation.Cursor failures are registered as non-retryable. Raising a real libtmux exception instead of a locally-built
ExpectedToolErrorhas a non-obvious consequence here:handle_tool_errors_asyncre-raises asExpectedToolError(...) from e, andReadonlyRetryMiddlewaredecides by walking__cause__againstLibTmuxException. Left alone, a malformed, cross-pane, dead-pane, or respawned-pane cursor would newly cost a backoff window and a duplicate tmux round-trip before failing identically.CaptureCursorErrorjoinsNON_RETRYABLE_EXCEPTIONSalongside the conceptually identicalPaneNotFound. Worth knowing for any future swap of a local error for a libtmux one.wait.pykeeps issuing its own state read. It reads through a timeout-boundedsubprocess.runrather than libtmux, becausePopen.communicate()has no timeout and can wedge a worker thread on the wait path. That constraint is unchanged, so the shared format constants and parser stay.Before merging
Pane.capture_since().[tool.uv.sources]block frompyproject.tomland re-lock.libtmux>=0.62.0,<1.0would otherwise admit a version withoutPane.capture_since(), and the import fails at load rather than at call.Companion PR
libtmux: tmux-python/libtmux#741
Verification
Confirm no cursor machinery remains in this repository:
$ rg -n '_CaptureCursor|_build_cursor|_decode_cursor|_read_delta|_read_stable_visible' src/Confirm the cursor type is imported from libtmux:
$ rg -n 'from libtmux.capture import' src/libtmux_mcp/tools/pane_tools/capture_since.pyConfirm response limiting still lives here:
$ rg -n 'def _limit_lines' src/libtmux_mcp/tools/pane_tools/capture_since.pyTest plan
uv run ruff check .— lint cleanuv run ruff format --check .— formatting cleanuv run mypy src tests— no type errorsuv run pytest -k capture_since— every existingcapture_sincetest passes with no test-side change, against the upstream implementationuv run pytest tests/test_middleware.py— cursor failures are not retried; both new cases fail ifCaptureCursorErroris dropped fromNON_RETRYABLE_EXCEPTIONSuv run pytest— full suitejust build-docs— builds with no warningsThe suite carries load-sensitive
wait_for_texttiming flakes that predate this branch. A full run here and a full run on pristinemaineach produced a failure, and the two failure sets were disjoint — a test that failed here passed onmain, and vice versa. Every one of them passes when re-run in isolation.