Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@
_Notes on upcoming releases will be added here_
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### 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)

**{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)

**{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.
Expand Down
5 changes: 5 additions & 0 deletions docs/tools/pane/search-panes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)`)
Expand Down
2 changes: 2 additions & 0 deletions docs/tools/pane/wait-for-content-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
8 changes: 7 additions & 1 deletion docs/tools/pane/wait-for-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
4 changes: 4 additions & 0 deletions src/libtmux_mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
16 changes: 12 additions & 4 deletions src/libtmux_mcp/tools/pane_tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down Expand Up @@ -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:
Expand Down
43 changes: 28 additions & 15 deletions src/libtmux_mcp/tools/pane_tools/wait.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -534,6 +541,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,
)


Expand All @@ -555,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
Expand Down Expand Up @@ -610,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)
Expand All @@ -627,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
Expand Down
117 changes: 114 additions & 3 deletions tests/test_pane_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1994,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}"
Expand Down Expand Up @@ -2125,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,
Expand All @@ -2136,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}"
Expand Down Expand Up @@ -2584,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
# ---------------------------------------------------------------------------
Expand Down