From a348a012e730dc7e293f53397eca8aeb08ff56c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Aug 2026 05:43:22 -0500 Subject: [PATCH 1/4] pane_tools(refactor[capture_since]): Read the cursor from libtmux 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: https://github.com/tmux-python/libtmux/pull/741 --- CHANGES | 16 + pyproject.toml | 6 + src/libtmux_mcp/_utils.py | 11 + .../tools/pane_tools/capture_since.py | 379 +----------------- src/libtmux_mcp/tools/pane_tools/state.py | 39 +- tests/test_pane_tools.py | 12 +- uv.lock | 8 +- 7 files changed, 68 insertions(+), 403 deletions(-) diff --git a/CHANGES b/CHANGES index b40708cb..3dd475a0 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,22 @@ _Notes on upcoming releases will be added here_ +### Development + +#### `capture_since` reads its cursor from libtmux + +The cursor machinery behind the `capture_since` tool — anchor arithmetic, +history-trim re-anchoring, the stable double-read, and the serialized cursor +format — now comes from libtmux's `Pane.capture_since()` instead of living +here. The tool keeps the part that is an MCP concern: bounding a response with +`max_lines` and `max_bytes` so one observation cannot blow an agent's context +window. + +The cursor wire format is unchanged, so cursors issued by earlier versions +still decode. A cursor that no longer describes its pane now raises libtmux's +`CaptureCursorError` family, which maps to an agent-facing error advising a +fresh cursor rather than the generic tmux-error wording. + ### Documentation #### opencode joins the install picker diff --git a/pyproject.toml b/pyproject.toml index 036f261a..10aff8e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,12 @@ lint = [ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.uv.sources] +# TEMPORARY: tracks the libtmux branch that adds Pane.capture_since(). +# Drop this block and the matching uv.lock entry once that lands in a +# released libtmux. https://github.com/tmux-python/libtmux/pull/741 +libtmux = { git = "https://github.com/tmux-python/libtmux.git", branch = "capture-since" } + [tool.uv.exclude-newer-package] # git-pull packages release in lockstep with their workspaces, so a # fresh release blocking on the 3-day cooldown blocks every diff --git a/src/libtmux_mcp/_utils.py b/src/libtmux_mcp/_utils.py index 587e2d85..1b13a98f 100644 --- a/src/libtmux_mcp/_utils.py +++ b/src/libtmux_mcp/_utils.py @@ -1069,6 +1069,17 @@ def _map_exception_to_tool_error(fn_name: str, e: BaseException) -> ToolError: f"Pane not found: {e}", suggestion="Call list_panes to discover valid pane ids.", ) + if isinstance(e, exc.CaptureCursorError): + # Not a tmux failure — the cursor the agent replayed no longer + # describes the pane it was taken from, so the generic "tmux + # error:" prefix would point at the wrong thing to fix. + return ExpectedToolError( + str(e), + suggestion=( + "Call capture_since without a cursor to start a fresh " + "observation of this pane." + ), + ) if isinstance(e, exc.LibTmuxException): return ExpectedToolError(f"tmux error: {e}") logger.exception("unexpected error in MCP tool %s", fn_name) diff --git a/src/libtmux_mcp/tools/pane_tools/capture_since.py b/src/libtmux_mcp/tools/pane_tools/capture_since.py index 5d6f77de..dddac45e 100644 --- a/src/libtmux_mcp/tools/pane_tools/capture_since.py +++ b/src/libtmux_mcp/tools/pane_tools/capture_since.py @@ -1,16 +1,20 @@ -"""Incremental capture tool for tmux pane observation.""" +"""Incremental capture tool for tmux pane observation. + +The cursor machinery — anchor arithmetic, trim-risk re-anchoring, the +stable double-read, and the serialized cursor format — lives in libtmux +as :meth:`~libtmux.pane.Pane.capture_since`. What remains here is the +part that is an MCP concern rather than a tmux one: bounding the +response so a single observation cannot blow an agent's context window. +""" from __future__ import annotations import asyncio -import base64 -import binascii -import hashlib -import json import time -import typing as t from dataclasses import dataclass +from libtmux.capture import CaptureCursor + from libtmux_mcp._utils import ( ExpectedToolError, _get_server, @@ -19,47 +23,10 @@ ) from libtmux_mcp.models import CaptureSinceResult from libtmux_mcp.tools.pane_tools.io import CAPTURE_DEFAULT_MAX_LINES -from libtmux_mcp.tools.pane_tools.state import ( - _PaneState, - _raise_if_pane_lifecycle_changed, - _read_history_limit, - _read_pane_state, -) - -if t.TYPE_CHECKING: - from libtmux.pane import Pane - CAPTURE_SINCE_DEFAULT_MAX_LINES = CAPTURE_DEFAULT_MAX_LINES CAPTURE_SINCE_DEFAULT_MAX_BYTES = 128_000 -_CURSOR_PREFIX = "capture-since-v1:" -_CURSOR_VERSION = 1 -_STABLE_READ_ATTEMPTS = 3 - - -@dataclass(frozen=True) -class _CaptureCursor: - """Decoded capture_since cursor payload.""" - - pane_id: str - pane_pid: str - history_size: int - pane_height: int - anchor_abs: int - anchor_hash: str | None - below_hashes: tuple[str, ...] - - -@dataclass(frozen=True) -class _PaneRead: - """Synchronous tmux read result used by the async tool wrapper.""" - - state: _PaneState - cursor_rows: list[str] - lines: list[str] - lines_missed: bool - @dataclass(frozen=True) class _LimitedLines: @@ -71,305 +38,6 @@ class _LimitedLines: truncated_bytes: int -def _line_hash(line: str) -> str: - """Return a stable content hash for a tmux row.""" - return hashlib.sha256(line.encode("utf-8", "surrogateescape")).hexdigest() - - -def _capture_rows( - pane: Pane, - *, - start: t.Literal["-"] | int | None = None, - end: t.Literal["-"] | int | None = None, -) -> list[str]: - """Return pane rows as a concrete list.""" - rows = pane.capture_pane(start=start, end=end) - if rows is None: - return [] - return list(rows) - - -def _capture_cursor_rows(pane: Pane, state: _PaneState) -> list[str]: - """Capture rows from the cursor through the visible bottom.""" - if state.cursor_y >= state.pane_height: - return [] - return _capture_rows(pane, start=state.cursor_y, end=None) - - -def _same_state(left: _PaneState, right: _PaneState) -> bool: - """Return True when two pane snapshots describe the same grid point.""" - return left == right - - -def _raise_if_dead_without_baseline(pane: Pane, state: _PaneState) -> None: - """Raise a tool error for a dead pane before a cursor exists.""" - if state.pane_dead: - msg = f"pane {pane.pane_id} died during pane read" - raise ExpectedToolError(msg) - - -def _read_stable_visible( - pane: Pane, - *, - baseline_pid: str | None = None, -) -> _PaneRead: - """Capture the visible pane and cursor rows with a stable state snapshot.""" - for _attempt in range(_STABLE_READ_ATTEMPTS): - before = _read_pane_state(pane) - if baseline_pid is None: - _raise_if_dead_without_baseline(pane, before) - expected_pid = before.pane_pid - else: - expected_pid = baseline_pid - _raise_if_pane_lifecycle_changed(pane.pane_id, before, expected_pid) - - lines = _capture_rows(pane) - cursor_rows = _capture_cursor_rows(pane, before) - after = _read_pane_state(pane) - _raise_if_pane_lifecycle_changed(pane.pane_id, after, expected_pid) - if _same_state(before, after): - return _PaneRead( - state=after, - cursor_rows=cursor_rows, - lines=lines, - lines_missed=False, - ) - - state = _read_pane_state(pane) - if baseline_pid is None: - _raise_if_dead_without_baseline(pane, state) - else: - _raise_if_pane_lifecycle_changed(pane.pane_id, state, baseline_pid) - return _PaneRead( - state=state, - cursor_rows=_capture_cursor_rows(pane, state), - lines=_capture_rows(pane), - lines_missed=True, - ) - - -def _cursor_anchor_lost(cursor: _CaptureCursor, state: _PaneState) -> bool: - """Return True when sampled state proves tmux lost the cursor anchor.""" - bottom_abs = state.history_size + state.pane_height - 1 - if cursor.anchor_abs > bottom_abs: - return True - # A complete history wipe (``clear-history``) always destroys the - # anchor regardless of pane height — the grid is reset to zero. - if state.history_size == 0 and cursor.history_size > 0: - return True - # ``anchor_abs < history_size`` means the anchor has scrolled into - # retained history, where ``capture-pane -S`` can still address it - # with a negative start offset. - # - # The ``pane_height`` guard distinguishes resize-grow (which pulls - # rows from history back into the visible region without freeing - # data) from actual trim (where row data is destroyed). - return state.history_size < cursor.history_size and ( - state.pane_height <= cursor.pane_height - ) - - -def _history_limit_trim_risk( - cursor: _CaptureCursor, - state: _PaneState, - history_limit: int, -) -> bool: - """Return True when tmux may have rebased retained-history rows.""" - if history_limit <= 0: - return True - trim_batch = max(history_limit // 10, 1) - risk_floor = history_limit - trim_batch - return cursor.history_size >= risk_floor or state.history_size >= risk_floor - - -def _find_unique_cursor_match(rows: list[str], cursor: _CaptureCursor) -> int | None: - """Find one retained row sequence matching the cursor fingerprint.""" - if cursor.anchor_hash is None: - return None - - fingerprint = (cursor.anchor_hash, *cursor.below_hashes) - if len(rows) < len(fingerprint): - return None - - match_index: int | None = None - for index in range(len(rows) - len(fingerprint) + 1): - candidate = rows[index : index + len(fingerprint)] - candidate_hashes = tuple(_line_hash(line) for line in candidate) - if candidate_hashes != fingerprint: - continue - if match_index is not None: - return None - match_index = index - return match_index - - -def _drop_previously_seen_rows( - rows: list[str], - cursor: _CaptureCursor, -) -> list[str]: - """Drop the cursor anchor and below-cursor rows already represented.""" - if not rows: - return [] - - output: list[str] = [] - tail = rows - if cursor.anchor_hash is not None and _line_hash(rows[0]) == cursor.anchor_hash: - tail = rows[1:] - else: - output.append(rows[0]) - tail = rows[1:] - - drop = 0 - for expected_hash, line in zip(cursor.below_hashes, tail, strict=False): - if _line_hash(line) != expected_hash: - break - drop += 1 - output.extend(tail[drop:]) - return output - - -def _read_delta(pane: Pane, cursor: _CaptureCursor) -> _PaneRead: - """Capture rows since ``cursor`` or fall back to visible content on loss.""" - history_limit = _read_history_limit(pane) - for _attempt in range(_STABLE_READ_ATTEMPTS): - before = _read_pane_state(pane) - _raise_if_pane_lifecycle_changed(pane.pane_id, before, cursor.pane_pid) - if _cursor_anchor_lost(cursor, before): - missed = _read_stable_visible(pane, baseline_pid=cursor.pane_pid) - return _PaneRead( - state=missed.state, - cursor_rows=missed.cursor_rows, - lines=missed.lines, - lines_missed=True, - ) - - trim_risk = _history_limit_trim_risk(cursor, before, history_limit) - start = cursor.anchor_abs - before.history_size - rows = ( - _capture_rows(pane, start="-", end=None) - if trim_risk - else ( - [] - if start >= before.pane_height - else _capture_rows(pane, start=start, end=None) - ) - ) - cursor_rows = _capture_cursor_rows(pane, before) - after = _read_pane_state(pane) - _raise_if_pane_lifecycle_changed(pane.pane_id, after, cursor.pane_pid) - if _same_state(before, after): - if trim_risk: - match_index = _find_unique_cursor_match(rows, cursor) - if match_index is None: - missed = _read_stable_visible(pane, baseline_pid=cursor.pane_pid) - return _PaneRead( - state=missed.state, - cursor_rows=missed.cursor_rows, - lines=missed.lines, - lines_missed=True, - ) - rows = rows[match_index:] - return _PaneRead( - state=after, - cursor_rows=cursor_rows, - lines=_drop_previously_seen_rows(rows, cursor), - lines_missed=False, - ) - - missed = _read_stable_visible(pane, baseline_pid=cursor.pane_pid) - return _PaneRead( - state=missed.state, - cursor_rows=missed.cursor_rows, - lines=missed.lines, - lines_missed=True, - ) - - -def _build_cursor(pane_id: str, state: _PaneState, cursor_rows: list[str]) -> str: - """Encode the current cursor anchor as an opaque string.""" - payload: dict[str, t.Any] = { - "version": _CURSOR_VERSION, - "pane_id": pane_id, - "pane_pid": state.pane_pid, - "history_size": state.history_size, - "pane_height": state.pane_height, - "anchor_abs": state.history_size + state.cursor_y, - "anchor_hash": _line_hash(cursor_rows[0]) if cursor_rows else None, - "below_hashes": [_line_hash(line) for line in cursor_rows[1:]], - } - raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() - encoded = base64.urlsafe_b64encode(raw).decode().rstrip("=") - return f"{_CURSOR_PREFIX}{encoded}" - - -def _raise_invalid_cursor(reason: str) -> t.NoReturn: - """Raise a consistently worded invalid-cursor error.""" - msg = f"invalid capture_since cursor: {reason}" - raise ExpectedToolError(msg) - - -def _cursor_str(payload: t.Mapping[str, t.Any], key: str) -> str: - """Read a required string from a cursor payload.""" - value = payload.get(key) - if not isinstance(value, str) or not value: - reason = f"missing or invalid {key}" - _raise_invalid_cursor(reason) - return value - - -def _cursor_int(payload: t.Mapping[str, t.Any], key: str) -> int: - """Read a required non-negative integer from a cursor payload.""" - value = payload.get(key) - if not isinstance(value, int) or isinstance(value, bool) or value < 0: - reason = f"missing or invalid {key}" - _raise_invalid_cursor(reason) - return value - - -def _decode_cursor(cursor: str) -> _CaptureCursor: - """Decode and validate an opaque ``capture_since`` cursor.""" - if not cursor.startswith(_CURSOR_PREFIX): - reason = "unsupported cursor format" - _raise_invalid_cursor(reason) - encoded = cursor.removeprefix(_CURSOR_PREFIX) - padding = "=" * (-len(encoded) % 4) - try: - raw = base64.urlsafe_b64decode(f"{encoded}{padding}") - payload: t.Any = json.loads(raw) - except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError) as err: - reason = "could not decode payload" - msg = f"invalid capture_since cursor: {reason}" - raise ExpectedToolError(msg) from err - - if not isinstance(payload, dict): - reason = "payload is not an object" - _raise_invalid_cursor(reason) - if payload.get("version") != _CURSOR_VERSION: - reason = "unsupported cursor version" - _raise_invalid_cursor(reason) - - anchor_hash_value = payload.get("anchor_hash") - if anchor_hash_value is not None and not isinstance(anchor_hash_value, str): - reason = "missing or invalid anchor_hash" - _raise_invalid_cursor(reason) - below_hashes_value = payload.get("below_hashes") - if not isinstance(below_hashes_value, list) or not all( - isinstance(item, str) for item in below_hashes_value - ): - reason = "missing or invalid below_hashes" - _raise_invalid_cursor(reason) - - return _CaptureCursor( - pane_id=_cursor_str(payload, "pane_id"), - pane_pid=_cursor_str(payload, "pane_pid"), - history_size=_cursor_int(payload, "history_size"), - pane_height=_cursor_int(payload, "pane_height"), - anchor_abs=_cursor_int(payload, "anchor_abs"), - anchor_hash=anchor_hash_value, - below_hashes=tuple(below_hashes_value), - ) - - def _validate_limits(max_lines: int | None, max_bytes: int | None) -> None: """Validate caller-supplied truncation limits.""" if max_lines is not None and max_lines <= 0: @@ -391,7 +59,13 @@ def _limit_lines( max_lines: int | None, max_bytes: int | None, ) -> _LimitedLines: - """Apply tail-preserving line and byte limits.""" + """Apply tail-preserving line and byte limits. + + Runs after the capture completes and never feeds back into the + cursor, which libtmux builds from pane state rather than from these + rows. Truncating a response therefore cannot shift where the next + observation resumes. + """ kept = list(lines) truncated_lines = 0 truncated_bytes = 0 @@ -485,7 +159,7 @@ async def capture_since( metadata. """ _validate_limits(max_lines, max_bytes) - decoded = _decode_cursor(cursor) if cursor is not None else None + decoded = CaptureCursor.from_str(cursor) if cursor is not None else None if decoded is not None and not any( value is not None for value in (pane_id, session_name, session_id, window_id) ): @@ -501,24 +175,15 @@ async def capture_since( ) assert pane.pane_id is not None - if decoded is not None and pane.pane_id != decoded.pane_id: - msg = ( - f"cursor pane {decoded.pane_id} does not match requested pane " - f"{pane.pane_id}" - ) - raise ExpectedToolError(msg) - start_time = time.monotonic() - if decoded is None: - read = await asyncio.to_thread(_read_stable_visible, pane) - else: - read = await asyncio.to_thread(_read_delta, pane, decoded) - + # Off the event loop: every tmux round-trip inside capture_since is a + # blocking subprocess call, and a stable read makes several. + read = await asyncio.to_thread(pane.capture_since, decoded) limited = _limit_lines(read.lines, max_lines=max_lines, max_bytes=max_bytes) elapsed = time.monotonic() - start_time return CaptureSinceResult( pane_id=pane.pane_id, - cursor=_build_cursor(pane.pane_id, read.state, read.cursor_rows), + cursor=str(read.cursor), lines=limited.lines, elapsed_seconds=round(elapsed, 3), lines_missed=read.lines_missed, diff --git a/src/libtmux_mcp/tools/pane_tools/state.py b/src/libtmux_mcp/tools/pane_tools/state.py index d0220aeb..3f68607a 100644 --- a/src/libtmux_mcp/tools/pane_tools/state.py +++ b/src/libtmux_mcp/tools/pane_tools/state.py @@ -6,9 +6,6 @@ from libtmux_mcp._utils import ExpectedToolError -if t.TYPE_CHECKING: - from libtmux.pane import Pane - class _PaneState(t.NamedTuple): """Per-read snapshot of tmux pane grid and lifecycle state. @@ -18,7 +15,7 @@ class _PaneState(t.NamedTuple): ``history_size + cursor_y`` gives the absolute tmux grid row of the current cursor. - Wire format parsed by :func:`_read_pane_state`:: + Wire format parsed by :func:`_parse_pane_state`:: #{history_size}|#{cursor_y}|#{pane_height}|#{pane_pid}|#{pane_dead} |#{alternate_on} @@ -45,9 +42,9 @@ class _PaneState(t.NamedTuple): alternate_on: bool = False -#: tmux format string read by :func:`_read_pane_state`. Exposed as a -#: constant because the wait tools re-issue the identical read through -#: a timeout-bounded ``subprocess.run`` rather than libtmux (whose +#: tmux format string whose single output line :func:`_parse_pane_state` +#: decodes. Exposed as a constant because the wait tools issue this read +#: through a timeout-bounded ``subprocess.run`` rather than libtmux (whose #: ``Popen.communicate()`` has no timeout and can wedge a worker #: thread). It is a fixed literal — no caller-supplied text is ever #: interpolated into a tmux format string, because tmux's format @@ -83,19 +80,6 @@ def _parse_pane_state(raw: str) -> _PaneState: ) -def _read_pane_state(pane: Pane) -> _PaneState: - """Return a :class:`_PaneState` snapshot for ``pane``. - - Combines the tmux state reads needed by wait and incremental - capture tools into a single ``display-message`` call. ``pane_pid`` - and ``pane_dead`` surface respawn-pane and pane-death events that - invalidate cursor or baseline anchors. - """ - stdout = pane.display_message(PANE_STATE_FORMAT, get_text=True) - raw = stdout[0] if stdout else "0|0|0||0" - return _parse_pane_state(raw) - - def _raise_if_pane_lifecycle_changed( pane_id: str | None, state: _PaneState, baseline_pid: str ) -> None: @@ -116,18 +100,3 @@ def _raise_if_pane_lifecycle_changed( "cursor/baseline anchor is no longer valid" ) raise ExpectedToolError(msg) - - -def _read_history_limit(pane: Pane) -> int: - """Read the pane's ``history-limit`` once. - - Fixed at pane creation — a retroactive ``set-option history-limit`` - only takes effect in tmux 3.7+ (commit ``e7b1575``); older versions - require a new pane. Safe to cache for the lifetime of a single - wait or capture operation. Kept separate from :func:`_read_pane_state` - so per-tick reads do not pay for a value that never changes between - ticks. - """ - stdout = pane.display_message(HISTORY_LIMIT_FORMAT, get_text=True) - raw = stdout[0] if stdout else "0" - return int(raw) diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 1e3d2ff4..48c2b2bf 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -1351,8 +1351,8 @@ def test_capture_since_marks_lines_missed_after_history_limit_trim( Floods past ``history-limit`` then clears history to guarantee the cursor anchor is destroyed. 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. + tmux 3.6 retains enough of the original prompt that libtmux's + fingerprint search re-anchors on the surviving hash. """ import asyncio @@ -1561,9 +1561,11 @@ def test_capture_since_marks_lines_missed_after_clear_history_with_resize( ) -> None: """clear-history + pane resize still detects anchor loss. - Regression: ``_cursor_anchor_lost`` used a ``pane_height`` guard - that returned False when the pane grew after ``clear-history``, - masking the complete history wipe. + Regression: libtmux's anchor-loss check uses a ``pane_height`` guard + to tell a resize-grow from a real trim, and an early version of it + returned False when the pane grew after ``clear-history``, masking + the complete history wipe. Kept here because this tool is what + surfaces that loss to an agent. """ import asyncio diff --git a/uv.lock b/uv.lock index f9912b58..ca7a707d 100644 --- a/uv.lock +++ b/uv.lock @@ -1324,11 +1324,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.62.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/b8/0410c8487f7673926d141a5aaaf60133984a564b3c90eb4f3f99e92e7740/libtmux-0.62.0.tar.gz", hash = "sha256:41e9e80602b2656fd119b13253b27bad46af5cfef32099be53cdd391e2936b61", size = 571757, upload-time = "2026-07-12T21:48:02.781Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/96/471ac01844ee157fe794446e17568e9bf219d323575c2acc7957a9a2d8c9/libtmux-0.62.0-py3-none-any.whl", hash = "sha256:626aa6fae45e3a423e1acc81604efafa63b7262b1d67b2c7a8778b1ad691456b", size = 127700, upload-time = "2026-07-12T21:48:01.52Z" }, -] +source = { git = "https://github.com/tmux-python/libtmux.git?branch=capture-since#ee6dcb75ccb096d5c54fcc6281804863e0a8fc7f" } [[package]] name = "libtmux-mcp" @@ -1391,7 +1387,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=3.4.2,<4.0.0" }, - { name = "libtmux", specifier = ">=0.62.0,<1.0" }, + { name = "libtmux", git = "https://github.com/tmux-python/libtmux.git?branch=capture-since" }, ] [package.metadata.requires-dev] From 80c0f6a5b403ad21c6b49c4a3f203f0ab9c67c09 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Aug 2026 06:19:14 -0500 Subject: [PATCH 2/4] middleware(fix[retry]): Stop retrying capture cursor failures 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 --- src/libtmux_mcp/middleware.py | 4 ++++ tests/test_middleware.py | 9 ++++++--- uv.lock | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/libtmux_mcp/middleware.py b/src/libtmux_mcp/middleware.py index 9226bf2a..d60fafae 100644 --- a/src/libtmux_mcp/middleware.py +++ b/src/libtmux_mcp/middleware.py @@ -692,6 +692,10 @@ async def on_call_tool( libtmux_exc.ObjectDoesNotExist, libtmux_exc.MultipleObjectsReturned, libtmux_exc.PaneNotFound, + # Covers InvalidCaptureCursor and PaneLifecycleChanged. A cursor that + # does not describe its pane describes it no better on a second look, + # and a respawned or dead pane does not un-respawn. + libtmux_exc.CaptureCursorError, libtmux_exc.NoWindowsExist, libtmux_exc.BadSessionName, libtmux_exc.TmuxSessionExists, diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 495e1532..b39fecbe 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -957,6 +957,8 @@ async def real_call_next(_context: t.Any) -> t.Any: libtmux_exc.NoWindowsExist, libtmux_exc.BadSessionName(reason="contains periods", session_name="a.b"), libtmux_exc.TmuxSessionExists("session exists"), + libtmux_exc.InvalidCaptureCursor("invalid capture_since cursor"), + libtmux_exc.PaneLifecycleChanged("pane %99 was respawned"), ], ids=lambda e: type(e).__name__ if isinstance(e, Exception) else e.__name__, ) @@ -966,9 +968,10 @@ def test_readonly_retry_skips_deterministic_failures(raised: Exception) -> None: Every one of these descends from ``LibTmuxException``, which is the retry trigger — so without :data:`NON_RETRYABLE_EXCEPTIONS` they would all be retried. None of them can succeed on the second look: a pane that is not - there will not appear during a backoff window, and an ambiguous match does - not become unambiguous. Retrying buys a second tmux round-trip and 100 ms - of latency in order to fail identically. + there will not appear during a backoff window, an ambiguous match does + not become unambiguous, and a capture cursor that does not describe its + pane will not start describing it. Retrying buys a second tmux round-trip + and 100 ms of latency in order to fail identically. """ middleware = ReadonlyRetryMiddleware(max_retries=1, base_delay=0.0) ctx = _retry_context(tags={TAG_READONLY}) diff --git a/uv.lock b/uv.lock index ca7a707d..159ccd22 100644 --- a/uv.lock +++ b/uv.lock @@ -1324,7 +1324,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.62.0" -source = { git = "https://github.com/tmux-python/libtmux.git?branch=capture-since#ee6dcb75ccb096d5c54fcc6281804863e0a8fc7f" } +source = { git = "https://github.com/tmux-python/libtmux.git?branch=capture-since#aafd8cc59f215ce57949c0a5e2e9f4ec54398610" } [[package]] name = "libtmux-mcp" From 68fc2f2feeca813cc89f13e6db4c5c9062840064 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Aug 2026 06:49:59 -0500 Subject: [PATCH 3/4] py(deps) Re-pin libtmux to the capture-since tip why: Keep CI resolving the branch commit the tool is developed against. --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 159ccd22..53eb75c2 100644 --- a/uv.lock +++ b/uv.lock @@ -1324,7 +1324,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.62.0" -source = { git = "https://github.com/tmux-python/libtmux.git?branch=capture-since#aafd8cc59f215ce57949c0a5e2e9f4ec54398610" } +source = { git = "https://github.com/tmux-python/libtmux.git?branch=capture-since#9c0137a873004230db13ba5355ba9d9ab01418ac" } [[package]] name = "libtmux-mcp" From 6e3fe545fb356947cbb6601ad1f22f9a9a596483 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Aug 2026 07:58:28 -0500 Subject: [PATCH 4/4] tests(fix[capture_since]): Slow the tmux chokepoint, not one wrapper 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 --- tests/test_pane_tools.py | 19 ++++++++++++++----- uv.lock | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 48c2b2bf..f131a1ed 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -1658,17 +1658,26 @@ def _is_dead() -> bool: def test_capture_since_does_not_block_event_loop( mcp_server: Server, mcp_pane: Pane, monkeypatch: pytest.MonkeyPatch ) -> None: - """``capture_since`` runs blocking tmux captures off the event loop.""" + """``capture_since`` runs blocking tmux captures off the event loop. + + Slows ``Pane.cmd``, the one chokepoint every tmux round-trip in a + capture passes through, rather than a single wrapper method. Patching + a specific wrapper would silently stop injecting delay if libtmux + changed which wrapper the read is built on, and the test would then + pass without exercising anything. + """ import asyncio import time as _time from libtmux.pane import Pane as _LibtmuxPane - def _slow_capture(self: _LibtmuxPane, *_a: object, **_kw: object) -> list[str]: - _time.sleep(0.15) - return [] + real_cmd = _LibtmuxPane.cmd + + def _slow_cmd(self: _LibtmuxPane, *args: str) -> t.Any: + _time.sleep(0.05) + return real_cmd(self, *args) - monkeypatch.setattr(_LibtmuxPane, "capture_pane", _slow_capture) + monkeypatch.setattr(_LibtmuxPane, "cmd", _slow_cmd) async def _drive() -> int: ticks = 0 diff --git a/uv.lock b/uv.lock index 53eb75c2..973f0ecd 100644 --- a/uv.lock +++ b/uv.lock @@ -1324,7 +1324,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.62.0" -source = { git = "https://github.com/tmux-python/libtmux.git?branch=capture-since#9c0137a873004230db13ba5355ba9d9ab01418ac" } +source = { git = "https://github.com/tmux-python/libtmux.git?branch=capture-since#447a1624d86926fafce3e10f5d4c5d4c3b5a6f1d" } [[package]] name = "libtmux-mcp"