From 1675a15464734cdfb5e9b0e20575e8ebc792947f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 24 May 2026 11:38:22 -0500 Subject: [PATCH 1/3] mcp(fix[search]): Join wrapped rows in search_panes slow path why: Long terminal output can wrap across tmux visual rows, causing slow-path search to miss patterns that span the wrap boundary. what: - Capture slow-path pane content with join_wrapped=True - Add a wrap-spanning search_panes regression - Document the fast-path wrap limitation and changelog fix --- CHANGES | 6 ++++ docs/tools/pane/search-panes.md | 5 +++ src/libtmux_mcp/tools/pane_tools/search.py | 16 ++++++--- tests/test_pane_tools.py | 40 ++++++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 4f880175..f5e859a2 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,12 @@ _Notes on upcoming releases will be added here_ +### Fixes + +**{tooliconl}`search-panes` matches wrap-spanning slow-path text** + +{tooliconl}`search-panes` now joins tmux-wrapped visual rows when it uses the captured-content slow path, so long build, test, or log lines can match across the pane width. Plain fast-path searches still use tmux's native visual-row search; pass `regex=True` or a content range when exact text may span a wrap boundary. (#55) + ## libtmux-mcp 0.1.0a8 (2026-05-23) libtmux-mcp 0.1.0a8 bumps libtmux to 0.58.0, fixing session and window listing on systems whose locale is not UTF-8. diff --git a/docs/tools/pane/search-panes.md b/docs/tools/pane/search-panes.md index 388ac764..0560906f 100644 --- a/docs/tools/pane/search-panes.md +++ b/docs/tools/pane/search-panes.md @@ -30,6 +30,11 @@ Response is a `SearchPanesResult` wrapper: the matching panes live under result sets, iterate by re-calling with `offset += len(matches)`; stop when `truncated == false` and `truncated_panes == []`. +Plain text searches with no content range use tmux's fast visual-row search. +That path is quick, but it cannot match text split by terminal wrapping. Pass +`regex=true` or a `content_start` / `content_end` range when long build, +test, or log lines may cross the pane's wrap column. + :::{note} Migrating from the flat-list shape Earlier alpha releases returned a bare `list[PaneContentMatch]`. Clients iterating the old shape directly (e.g. `for m in search_panes(...)`) diff --git a/src/libtmux_mcp/tools/pane_tools/search.py b/src/libtmux_mcp/tools/pane_tools/search.py index 268be7fa..cfc9512d 100644 --- a/src/libtmux_mcp/tools/pane_tools/search.py +++ b/src/libtmux_mcp/tools/pane_tools/search.py @@ -100,9 +100,13 @@ def search_panes( ``limit``. Each matching pane's ``matched_lines`` is further tail-truncated to at most ``max_matched_lines_per_pane`` entries (most-recent lines preserved). Caps apply only to the slow path - (``pane.capture_pane()`` + Python regex); the tmux fast path at - ``#{C:pattern}`` returns pane IDs only and is already bounded by - tmux. + (``pane.capture_pane(join_wrapped=True)`` + Python regex); the tmux + fast path at ``#{C:pattern}`` returns pane IDs only and is already + bounded by tmux. + The slow path joins wrapped visual rows so long lines can match + across the pane's wrap column. The fast path remains tmux's native + visual-row search, so use ``regex=True`` or an explicit content + range to force the slow path when wrap-spanning text matters. Parameters ---------- @@ -231,7 +235,11 @@ def search_panes( if pane is None: continue - lines = pane.capture_pane(start=content_start, end=content_end) + lines = pane.capture_pane( + start=content_start, + end=content_end, + join_wrapped=True, + ) matched_lines = [line for line in lines if compiled.search(line)] if not matched_lines: diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index e7dba3fd..fdd42dcb 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -1062,6 +1062,46 @@ def test_search_panes_per_pane_matched_lines_cap( assert result.truncated is True +def test_search_panes_matches_pattern_across_wrap_slow_path( + mcp_server: Server, mcp_session: Session, mcp_pane: Pane +) -> None: + """Slow-path search joins wrapped visual rows before matching.""" + import asyncio + import uuid + + from libtmux_mcp.tools.wait_for_tools import wait_for_channel + + width_raw = mcp_pane.display_message("#{pane_width}", get_text=True) + assert width_raw is not None + pane_width = int(width_raw[0]) + + filler_len = max(1, pane_width - 5) + marker = "WRAPPED_MARKER_xyz" + channel = f"mcp_test_search_wrap_{uuid.uuid4().hex[:16]}" + payload = ( + f"printf 'x%.0s' $(seq 1 {filler_len}); " + "printf 'WRA'; printf 'PPED_MARKER'; printf '_xyz'; echo; " + f"tmux wait-for -S {channel}" + ) + mcp_pane.send_keys(payload, enter=True) + asyncio.run( + wait_for_channel( + channel=channel, timeout=5.0, socket_name=mcp_server.socket_name + ) + ) + + result = search_panes( + pattern=marker, + session_name=mcp_session.session_name, + content_start=-100, + socket_name=mcp_server.socket_name, + ) + + match = next((m for m in result.matches if m.pane_id == mcp_pane.pane_id), None) + assert match is not None + assert any(marker in line for line in match.matched_lines) + + # --------------------------------------------------------------------------- # search_panes is_caller annotation tests # --------------------------------------------------------------------------- From 13c0f19b86c158c82f75ba1ae87c2eadc00a69c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 24 May 2026 11:41:31 -0500 Subject: [PATCH 2/3] mcp(feat[wait]): Surface wait_for_text risk-band state why: Some MCP clients do not deliver warning notifications back to callers, so risk-band state needs to be visible in the typed result. what: - Add risk_band_warned to WaitForTextResult - Return the existing trim-risk warning state from wait_for_text - Cover ordinary and risk-band result paths in tests and docs --- CHANGES | 4 ++++ docs/tools/pane/wait-for-text.md | 8 +++++++- src/libtmux_mcp/models.py | 4 ++++ src/libtmux_mcp/tools/pane_tools/wait.py | 1 + tests/test_pane_tools.py | 8 +++++--- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index f5e859a2..8e35627f 100644 --- a/CHANGES +++ b/CHANGES @@ -12,6 +12,10 @@ _Notes on upcoming releases will be added here_ {tooliconl}`search-panes` now joins tmux-wrapped visual rows when it uses the captured-content slow path, so long build, test, or log lines can match across the pane width. Plain fast-path searches still use tmux's native visual-row search; pass `regex=True` or a content range when exact text may span a wrap boundary. (#55) +**{tooliconl}`wait-for-text` reports trim-risk warnings in results** + +{tooliconl}`wait-for-text` now returns `risk_band_warned=True` when polling enters tmux's history-limit trim-risk band. Clients that do not surface MCP warning notifications can still detect that matching was best-effort and switch to {tooliconl}`wait-for-channel` for deterministic command completion. (#54) + ## libtmux-mcp 0.1.0a8 (2026-05-23) libtmux-mcp 0.1.0a8 bumps libtmux to 0.58.0, fixing session and window listing on systems whose locale is not UTF-8. diff --git a/docs/tools/pane/wait-for-text.md b/docs/tools/pane/wait-for-text.md index f7cd9b68..957e46c4 100644 --- a/docs/tools/pane/wait-for-text.md +++ b/docs/tools/pane/wait-for-text.md @@ -35,9 +35,15 @@ Response: "Server listening on port 8000" ], "pane_id": "%2", - "elapsed_seconds": 0.002 + "elapsed_seconds": 0.002, + "risk_band_warned": false } ``` +`risk_band_warned` is `true` when polling entered tmux's history-limit +trim-risk band. In that state, matching remains best-effort because older +scrollback can be discarded while the wait is active; use +{tooliconl}`wait-for-channel` for deterministic command completion. + ```{fastmcp-tool-input} pane_tools.wait_for_text ``` diff --git a/src/libtmux_mcp/models.py b/src/libtmux_mcp/models.py index 866bd170..c46cb00b 100644 --- a/src/libtmux_mcp/models.py +++ b/src/libtmux_mcp/models.py @@ -237,6 +237,10 @@ class WaitForTextResult(BaseModel): ) pane_id: str = Field(description="Pane ID that was polled") elapsed_seconds: float = Field(description="Time spent waiting in seconds") + risk_band_warned: bool = Field( + default=False, + description="Whether polling entered the history-limit trim-risk band", + ) class PaneSnapshot(BaseModel): diff --git a/src/libtmux_mcp/tools/pane_tools/wait.py b/src/libtmux_mcp/tools/pane_tools/wait.py index 924634b0..ae822be8 100644 --- a/src/libtmux_mcp/tools/pane_tools/wait.py +++ b/src/libtmux_mcp/tools/pane_tools/wait.py @@ -534,6 +534,7 @@ async def wait_for_text( matched_lines=matched_lines, pane_id=pane.pane_id, elapsed_seconds=round(elapsed, 3), + risk_band_warned=warned_risk_band, ) diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index fdd42dcb..7e37f33e 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -2034,6 +2034,7 @@ async def warning(self, message: str) -> None: ) assert result.found is False + assert result.risk_band_warned is False assert any( level == "warning" and "timeout" in msg.lower() for level, msg in log_calls ), f"expected a timeout warning, got: {log_calls}" @@ -2165,9 +2166,9 @@ async def report_progress(self, *args: t.Any, **kwargs: t.Any) -> None: async def warning(self, message: str) -> None: log_calls.append(("warning", message)) - async def run() -> None: + async def run() -> WaitForTextResult: # Idle wait: no new output, no cursor movement. - await wait_for_text( + return await wait_for_text( pattern="NEVER_MATCH_idle_risk", pane_id=fresh_pane.pane_id, timeout=0.5, @@ -2176,8 +2177,9 @@ async def run() -> None: ctx=t.cast("t.Any", _RecordingContext()), ) - asyncio.run(run()) + result = asyncio.run(run()) + assert result.risk_band_warned is True assert any( level == "warning" and "trim-risk band" in msg for level, msg in log_calls ), f"expected a trim-risk-band warning during idle wait, got: {log_calls}" From ba545e556c59f3c7d186258465e8b515b4ed7c57 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 24 May 2026 11:46:51 -0500 Subject: [PATCH 3/3] mcp(fix[wait]): Detect lifecycle changes in content waits why: Pane death or respawn invalidates the content-change baseline even when captured text is unchanged or misleading. what: - Share pane lifecycle validation with wait_for_text - Raise ToolError from wait_for_content_change on death or respawn - Add lifecycle regression coverage and docs/changelog notes --- CHANGES | 4 ++ docs/tools/pane/wait-for-content-change.md | 2 + src/libtmux_mcp/tools/pane_tools/wait.py | 42 ++++++++----- tests/test_pane_tools.py | 69 ++++++++++++++++++++++ 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/CHANGES b/CHANGES index 8e35627f..19543e88 100644 --- a/CHANGES +++ b/CHANGES @@ -16,6 +16,10 @@ _Notes on upcoming releases will be added here_ {tooliconl}`wait-for-text` now returns `risk_band_warned=True` when polling enters tmux's history-limit trim-risk band. Clients that do not surface MCP warning notifications can still detect that matching was best-effort and switch to {tooliconl}`wait-for-channel` for deterministic command completion. (#54) +**{tooliconl}`wait-for-content-change` fails on pane lifecycle changes** + +{tooliconl}`wait-for-content-change` now raises a tool error when the watched pane dies or is respawned during the wait. Those lifecycle changes invalidate the entry content baseline, so callers no longer receive a misleading `changed=True` result for a different pane process. (#53) + ## libtmux-mcp 0.1.0a8 (2026-05-23) libtmux-mcp 0.1.0a8 bumps libtmux to 0.58.0, fixing session and window listing on systems whose locale is not UTF-8. diff --git a/docs/tools/pane/wait-for-content-change.md b/docs/tools/pane/wait-for-content-change.md index a6ed108f..3f710652 100644 --- a/docs/tools/pane/wait-for-content-change.md +++ b/docs/tools/pane/wait-for-content-change.md @@ -12,6 +12,8 @@ specific pattern. precise and avoids false positives from unrelated output. **Side effects:** None. Readonly. Blocks until content changes or timeout. +Raises a tool error if the pane dies or is respawned while waiting, because the +entry content baseline no longer describes the same pane process. **Example:** diff --git a/src/libtmux_mcp/tools/pane_tools/wait.py b/src/libtmux_mcp/tools/pane_tools/wait.py index ae822be8..1ac951b6 100644 --- a/src/libtmux_mcp/tools/pane_tools/wait.py +++ b/src/libtmux_mcp/tools/pane_tools/wait.py @@ -100,7 +100,7 @@ async def _maybe_log( class _PaneState(t.NamedTuple): - """Per-tick snapshot of pane state used by :func:`wait_for_text`. + """Per-tick snapshot of pane state used by wait tools. Read in one ``display-message`` round-trip so the loop costs two subprocesses per tick (state + capture) instead of growing @@ -140,6 +140,22 @@ def _read_pane_state(pane: Pane) -> _PaneState: ) +def _raise_if_pane_lifecycle_changed( + pane: Pane, state: _PaneState, baseline_pid: str +) -> None: + """Raise ``ToolError`` when a wait baseline no longer describes the pane.""" + if state.pane_dead: + msg = f"pane {pane.pane_id} died during wait" + raise ToolError(msg) + if state.pane_pid != baseline_pid: + msg = ( + f"pane {pane.pane_id} was respawned during wait " + f"(pid {baseline_pid} -> {state.pane_pid}); " + "baseline anchor no longer valid" + ) + raise ToolError(msg) + + def _read_history_limit(pane: Pane) -> int: """Read the pane's ``history-limit`` once. @@ -407,16 +423,7 @@ async def wait_for_text( # blocking subprocess.run. Push to the default executor so # concurrent tool calls are not starved during long waits. state = await asyncio.to_thread(_read_pane_state, pane) - if state.pane_dead: - msg = f"pane {pane.pane_id} died during wait" - raise ToolError(msg) - if state.pane_pid != baseline_pid: - msg = ( - f"pane {pane.pane_id} was respawned during wait " - f"(pid {baseline_pid} -> {state.pane_pid}); " - "baseline anchor no longer valid" - ) - raise ToolError(msg) + _raise_if_pane_lifecycle_changed(pane, state, baseline_pid) # When tmux's ``history-limit`` is reached, ``grid_collect_history`` # (grid.c) frees the oldest scrollback rows and decrements # ``gd->hsize``, so absolute index math anchored on @@ -556,10 +563,9 @@ async def wait_for_content_change( what the output will be — it waits for "something happened" rather than a specific pattern. - Unlike ``wait_for_text``, this tool does not raise ``ToolError`` on - pane respawn, pane death, or ``clear-history`` mid-wait — those events - surface as ``changed=True`` returns instead. For correctness-sensitive - flows prefer ``wait_for_channel`` composed with ``tmux wait-for -S``. + Raises ``ToolError`` when pane respawn or pane death invalidates the + baseline captured at entry. For correctness-sensitive flows prefer + ``wait_for_channel`` composed with ``tmux wait-for -S``. Emits :meth:`fastmcp.Context.report_progress` each tick when a Context is injected, so clients can render a progress indicator @@ -611,6 +617,10 @@ async def wait_for_content_change( ) assert pane.pane_id is not None + entry = await asyncio.to_thread(_read_pane_state, pane) + baseline_pid = entry.pane_pid + _raise_if_pane_lifecycle_changed(pane, entry, baseline_pid) + # See comment in wait_for_text: push the blocking capture off the # main event loop via asyncio.to_thread. initial_content = await asyncio.to_thread(pane.capture_pane) @@ -628,6 +638,8 @@ async def wait_for_content_change( message=f"Watching pane {pane.pane_id} for change", ) + state = await asyncio.to_thread(_read_pane_state, pane) + _raise_if_pane_lifecycle_changed(pane, state, baseline_pid) current = await asyncio.to_thread(pane.capture_pane) if current != initial_content: changed = True diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 7e37f33e..7d28705f 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -2626,6 +2626,75 @@ def test_wait_for_content_change_timeout(mcp_server: Server, mcp_pane: Pane) -> assert result.changed is False +def test_wait_for_content_change_raises_on_pane_respawn( + mcp_server: Server, mcp_pane: Pane +) -> None: + """Respawning the pane mid-wait invalidates the content baseline.""" + import asyncio + + original_pid = mcp_pane.display_message("#{pane_pid}", get_text=True) + assert original_pid + + async def respawn_after_delay() -> None: + await asyncio.sleep(0.1) + await asyncio.to_thread(mcp_pane.respawn, kill=True, shell="sleep 30") + + def _pid_changed() -> bool: + current_pid = mcp_pane.display_message("#{pane_pid}", get_text=True) + return bool(current_pid) and current_pid[0] != original_pid[0] + + await asyncio.to_thread(retry_until, _pid_changed, 3, raises=True) + + async def run() -> ContentChangeResult: + wait_task = asyncio.create_task( + wait_for_content_change( + pane_id=mcp_pane.pane_id, + timeout=3.0, + interval=0.25, + socket_name=mcp_server.socket_name, + ) + ) + await respawn_after_delay() + return await wait_task + + with pytest.raises(ToolError, match="respawned during wait"): + asyncio.run(run()) + + +def test_wait_for_content_change_raises_on_pane_death( + mcp_server: Server, mcp_pane: Pane +) -> None: + """A pane whose process exits mid-wait invalidates the content baseline.""" + import asyncio + + mcp_pane.window.set_option("remain-on-exit", "on") + + async def exit_after_delay() -> None: + await asyncio.sleep(0.1) + await asyncio.to_thread(mcp_pane.respawn, kill=True, shell="true") + + def _is_dead() -> bool: + flag = mcp_pane.display_message("#{pane_dead}", get_text=True) + return bool(flag) and flag[0] == "1" + + await asyncio.to_thread(retry_until, _is_dead, 3, raises=True) + + async def run() -> ContentChangeResult: + wait_task = asyncio.create_task( + wait_for_content_change( + pane_id=mcp_pane.pane_id, + timeout=3.0, + interval=0.25, + socket_name=mcp_server.socket_name, + ) + ) + await exit_after_delay() + return await wait_task + + with pytest.raises(ToolError, match="died during wait"): + asyncio.run(run()) + + # --------------------------------------------------------------------------- # select_pane tests # ---------------------------------------------------------------------------