Adopt libtmux Pane.capture_since() for the capture_since tool - #122
Draft
tony wants to merge 4 commits into
Draft
Conversation
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.