Skip to content

Adopt libtmux Pane.capture_since() for the capture_since tool - #122

Draft
tony wants to merge 4 commits into
mainfrom
capture-since
Draft

Adopt libtmux Pane.capture_since() for the capture_since tool#122
tony wants to merge 4 commits into
mainfrom
capture-since

Conversation

@tony

@tony tony commented Aug 15, 2026

Copy link
Copy Markdown
Member

Important

Not mergeable yet. This branch pins libtmux to an unreleased branch. See Before merging below.

Summary

  • Replace the capture_since tool's local read driver with libtmux's Pane.capture_since() — the cursor codec, anchor-loss and trim-risk checks, fingerprint re-anchoring, and the stable double-read all move upstream.
  • Keep max_lines / max_bytes truncation here. Bounding a response so one observation cannot blow an agent's context window is an MCP concern, not a tmux one.
  • Map libtmux's CaptureCursorError family to an agent-facing error that advises taking a fresh cursor, ahead of the generic tmux-error catch-all.
  • Drop the state.py readers only this tool used. wait.py keeps the formats and parser it issues through its own timeout-bounded subprocess.
  • Pin libtmux to the branch that adds 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: Calls Pane.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 that wait.py imports; the two libtmux-backed readers go with the tool that used them.
  • _utils.py: A CaptureCursorError branch 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_since returns unbounded lines, matching capture_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.

CaptureCursorError gets its own mapping rather than falling through. The generic branch would render a replayed-cursor mistake as tmux 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 ExpectedToolError has a non-obvious consequence here: handle_tool_errors_async re-raises as ExpectedToolError(...) from e, and ReadonlyRetryMiddleware decides by walking __cause__ against LibTmuxException. 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. CaptureCursorError joins NON_RETRYABLE_EXCEPTIONS alongside the conceptually identical PaneNotFound. Worth knowing for any future swap of a local error for a libtmux one.

wait.py keeps issuing its own state read. It reads through a timeout-bounded subprocess.run rather than libtmux, because Popen.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

  1. Land the companion libtmux PR and cut a libtmux release containing Pane.capture_since().
  2. Remove the [tool.uv.sources] block from pyproject.toml and re-lock.
  3. Raise the libtmux floor to that release. The current libtmux>=0.62.0,<1.0 would otherwise admit a version without Pane.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.py

Confirm response limiting still lives here:

$ rg -n 'def _limit_lines' src/libtmux_mcp/tools/pane_tools/capture_since.py

Test plan

  • uv run ruff check . — lint clean
  • uv run ruff format --check . — formatting clean
  • uv run mypy src tests — no type errors
  • uv run pytest -k capture_since — every existing capture_since test passes with no test-side change, against the upstream implementation
  • uv run pytest tests/test_middleware.py — cursor failures are not retried; both new cases fail if CaptureCursorError is dropped from NON_RETRYABLE_EXCEPTIONS
  • uv run pytest — full suite
  • just build-docs — builds with no warnings

The suite carries load-sensitive wait_for_text timing flakes that predate this branch. A full run here and a full run on pristine main each produced a failure, and the two failure sets were disjoint — a test that failed here passed on main, and vice versa. Every one of them passes when re-run in isolation.

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
@codecov-commenter

codecov-commenter commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.43%. Comparing base (b90c58b) to head (6e3fe54).
⚠️ Report is 1 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 3 commits August 15, 2026 06:19
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
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