From 50b01812f8bc4a721a819fea2800902dbaa2d9f5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 02:44:22 -0500 Subject: [PATCH 1/9] py(deps): Pin optional tmux policy support why: Matching package versions do not identify the new guards. what: - Pin the published policy-2 source in project metadata and lock. - Retain the matching package range and other locked dependencies. --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4ce15372..523bf09c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ build-backend = "hatchling.build" [tool.uv.sources] # Coordinated version-check backend, pending its package release. -libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "f6e4630a15f342382487a60e1e1477abcd52294d" } +libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "5df9cb3e89647577b7dd8484f844ffe1053129bb" } [tool.uv.exclude-newer-package] # git-pull packages release in lockstep with their workspaces, so a diff --git a/uv.lock b/uv.lock index 260a8a00..ccf6e525 100644 --- a/uv.lock +++ b/uv.lock @@ -1492,7 +1492,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.62.0" -source = { git = "https://github.com/tmux-python/libtmux.git?rev=f6e4630a15f342382487a60e1e1477abcd52294d#f6e4630a15f342382487a60e1e1477abcd52294d" } +source = { git = "https://github.com/tmux-python/libtmux.git?rev=5df9cb3e89647577b7dd8484f844ffe1053129bb#5df9cb3e89647577b7dd8484f844ffe1053129bb" } [[package]] name = "libtmux-mcp" @@ -1558,7 +1558,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=4.0.2,<5.0.0" }, - { name = "libtmux", git = "https://github.com/tmux-python/libtmux.git?rev=f6e4630a15f342382487a60e1e1477abcd52294d" }, + { name = "libtmux", git = "https://github.com/tmux-python/libtmux.git?rev=5df9cb3e89647577b7dd8484f844ffe1053129bb" }, { name = "regex", specifier = ">=2024.11.6" }, ] From c95ac4f6e1c87a56463c980ec9e7d41d88098f1b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 02:50:17 -0500 Subject: [PATCH 2/9] Features(fix): Guard owned optional tmux calls why: MCP-owned children bypass parent methods, and paste needs a safe cleanup path before it creates a native buffer. what: - Require policy 2 before helper version checks and forward literal checks. - Carry optional minimums within existing owned-child time budgets. - Require 3.4 for nonempty managed paste before native staging. - Keep empty paste and ordinary buffer operations on tmux 3.3a. - Exercise refusal, unchanged user buffers and retained layout behavior. --- src/libtmux_mcp/_tmux_proc.py | 24 ++-- src/libtmux_mcp/tools/buffer_tools.py | 3 + src/libtmux_mcp/tools/pane_tools/io.py | 8 +- src/libtmux_mcp/tools/window_tools.py | 5 +- tests/test_buffer_tools.py | 31 ++++- tests/test_pane_tools.py | 20 +++- tests/test_tmux_features.py | 155 +++++++++++++++++++++++++ 7 files changed, 227 insertions(+), 19 deletions(-) create mode 100644 tests/test_tmux_features.py diff --git a/src/libtmux_mcp/_tmux_proc.py b/src/libtmux_mcp/_tmux_proc.py index 8042651a..97a63bcf 100644 --- a/src/libtmux_mcp/_tmux_proc.py +++ b/src/libtmux_mcp/_tmux_proc.py @@ -148,7 +148,7 @@ def _backend() -> types.ModuleType: raise exc.LibTmuxException(msg) from error if not all( callable(getattr(support, name, None)) - for name in ("require_version", "check_fence") + for name in ("require_version", "check_fence", "require_commands") ) or not isinstance(getattr(support, "MINIMUM", None), str): msg = "Unsupported libtmux backend: the tmux version check is unavailable" raise exc.LibTmuxException(msg) @@ -247,13 +247,17 @@ def _remaining(deadline: float) -> float: return result -def _fence(argv: list[str], version: str, live: bool) -> tuple[list[str], str | None]: - _backend().require_version(version, minimum=_MINIMUM_TMUX) +def _fence( + argv: list[str], version: str, live: bool, *, minimum: str = _MINIMUM_TMUX +) -> tuple[list[str], str | None]: + policy = _backend() + policy.require_version(version, minimum=minimum) _, commands = _parts(argv) prefix = argv[: len(argv) - len(commands)] if commands else argv marker = "libtmux-mcp-version-" + uuid.uuid4().hex # These callers select one native command; preserve literal terminal text. literal = [item[:-1] + "\\;" if item.endswith(";") else item for item in commands] + policy.require_commands(version, literal, repaired_break=False) return [ *prefix, "-u", @@ -269,13 +273,13 @@ def _fence(argv: list[str], version: str, live: bool) -> tuple[list[str], str | async def _prepare_bounded( - argv: list[str], deadline: float + argv: list[str], deadline: float, *, minimum: str = _MINIMUM_TMUX ) -> tuple[list[str], str | None]: _backend() selectors, _commands = _parts(argv) cached = _VERSION_CACHE.get(_endpoint_key(argv[0], selectors)) if cached is not None: - return _fence(argv, cached, True) + return _fence(argv, cached, True, minimum=minimum) probe, marker = _probe(argv[0], selectors) code, output, error = await _run_tmux_unchecked(probe, timeout=_remaining(deadline)) live = not _absent(code, error) @@ -287,17 +291,17 @@ async def _prepare_bounded( [argv[0], "-V"], timeout=_remaining(deadline) ) version = _version(output, error, code, binary=argv[0]) - return _fence(argv, version, live) + return _fence(argv, version, live, minimum=minimum) async def _run_tmux_bounded( - argv: list[str], *, timeout: float + argv: list[str], *, timeout: float, minimum: str = _MINIMUM_TMUX ) -> tuple[int, bytes, bytes]: """Check the version and dispatch within one cancellable process budget.""" deadline = time.monotonic() + timeout selectors, _ = _parts(argv) for attempt in (0, 1): - prepared, marker = await _prepare_bounded(argv, deadline) + prepared, marker = await _prepare_bounded(argv, deadline, minimum=minimum) result = await _run_tmux_unchecked(prepared, timeout=_remaining(deadline)) stderr = result[2] if attempt == 0 and _absent(result[0], stderr): @@ -316,7 +320,7 @@ async def _run_tmux_bounded( def _run_tmux_sync_bounded( - argv: list[str], *, timeout: float, check: bool = True + argv: list[str], *, timeout: float, check: bool = True, minimum: str = _MINIMUM_TMUX ) -> subprocess.CompletedProcess[bytes]: """Check the version and run a synchronous child within one remaining budget.""" policy = _backend() @@ -345,7 +349,7 @@ def run(command: list[str]) -> subprocess.CompletedProcess[bytes]: version = _version( probe.stdout, probe.stderr, probe.returncode, binary=argv[0] ) - prepared, marker = _fence(argv, version, live) + prepared, marker = _fence(argv, version, live, minimum=minimum) result = run(prepared) if ( attempt == 0 diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index b9626ec8..56e7ec95 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -346,6 +346,9 @@ def delete_buffer( ) -> str: """Delete an MCP-owned buffer. + Requires tmux 3.4 for exact named-buffer deletion. Older daemons refuse + before modifying their buffer list. + Parameters ---------- buffer_name : str diff --git a/src/libtmux_mcp/tools/pane_tools/io.py b/src/libtmux_mcp/tools/pane_tools/io.py index 68358f57..bb097979 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -767,6 +767,9 @@ def paste_text( you need to stage content first, paste it into multiple panes, or inspect it with ``show_buffer`` before pasting. + Nonempty text requires tmux 3.4 for reliable named-buffer cleanup on + failure. Empty text has no input effect on every supported release. + Parameters ---------- text : str @@ -800,6 +803,9 @@ def paste_text( window_id=window_id, ) + if not text: + return f"Text pasted to pane {pane.pane_id}" + # Use a unique named tmux buffer so we don't clobber the user's # unnamed paste buffer, and so we can reliably clean up on error # paths (paste-buffer -b NAME -d deletes the named buffer). The @@ -819,7 +825,7 @@ def paste_text( load_args = _tmux_argv(server, "load-buffer", "-b", buffer_name, tmppath) try: - _run_tmux_sync_bounded(load_args, timeout=5.0) + _run_tmux_sync_bounded(load_args, timeout=5.0, minimum="3.4") except subprocess.TimeoutExpired as e: msg = f"load-buffer timeout after 5s for {buffer_name!r}" raise ExpectedToolError(msg) from e diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index a170de44..31bd2203 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -357,13 +357,16 @@ def select_layout( Choose from: even-horizontal, even-vertical, main-horizontal, main-vertical, or tiled. Rearranges all panes in the window. + On tmux 3.3a, unknown names and malformed serialized headers are + refused before dispatch. Valid layouts retain the core minimum. + Parameters ---------- layout : str Layout name or custom layout string. Built-in layouts: 'even-horizontal', 'even-vertical', 'main-horizontal', 'main-horizontal-mirrored', 'main-vertical', - 'main-vertical-mirrored', 'tiled'. + 'main-vertical-mirrored', 'tiled'. Mirrored layouts require tmux 3.5. window_id : str, optional Window ID (e.g. '@1'). window_index : str, optional diff --git a/tests/test_buffer_tools.py b/tests/test_buffer_tools.py index 822bdb56..3a46ddec 100644 --- a/tests/test_buffer_tools.py +++ b/tests/test_buffer_tools.py @@ -6,6 +6,7 @@ import pytest from fastmcp.exceptions import ToolError +from libtmux._internal import support from libtmux_mcp.middleware import _SENSITIVE_ARG_NAMES from libtmux_mcp.models import BufferContent, BufferRef @@ -23,6 +24,15 @@ from libtmux.server import Server +def _assert_delete_policy(server: Server, name: str) -> None: + """Assert exact deletion or an older-daemon refusal during fixture cleanup.""" + if support.is_at_least(server._get_version(), "3.4"): + delete_buffer(name, socket_name=server.socket_name) + else: + with pytest.raises(ToolError, match=r"3\.4"): + delete_buffer(name, socket_name=server.socket_name) + + @pytest.mark.parametrize("name", ["my-buffer", "clipboard.v2", "a", "x" * 64]) def test_logical_name_accepts_valid(name: str) -> None: """Valid logical names pass through.""" @@ -81,8 +91,8 @@ def test_load_buffer_allocates_unique_name(mcp_server: Server) -> None: # Clean up — buffers are server-global; leaking them would pollute # the test run even though the libtmux Server fixture is torn down. - delete_buffer(a.buffer_name, socket_name=mcp_server.socket_name) - delete_buffer(b.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, a.buffer_name) + _assert_delete_policy(mcp_server, b.buffer_name) @pytest.mark.usefixtures("mcp_session") @@ -101,6 +111,15 @@ def test_buffer_round_trip(mcp_server: Server) -> None: # comparison. assert seen.content.rstrip("\n") == payload + if not support.is_at_least(mcp_server._get_version(), "3.4"): + _assert_delete_policy(mcp_server, ref.buffer_name) + assert ( + show_buffer( + ref.buffer_name, socket_name=mcp_server.socket_name + ).content.rstrip("\n") + == payload + ) + return result = delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) assert "deleted" in result @@ -135,7 +154,7 @@ def test_paste_buffer_into_pane(mcp_server: Server, mcp_pane: Pane) -> None: ) assert ref.buffer_name in result finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) def test_content_is_in_sensitive_args() -> None: @@ -173,7 +192,7 @@ def test_show_buffer_tail_preserves_on_truncation(mcp_server: Server) -> None: kept = seen.content.splitlines() assert kept == [f"line-{i}" for i in range(15, 20)] finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) @pytest.mark.usefixtures("mcp_session") @@ -195,7 +214,7 @@ def test_show_buffer_full_read_when_max_lines_none(mcp_server: Server) -> None: assert seen.content_truncated_lines == 0 assert seen.content.rstrip("\n") == payload finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) @pytest.mark.usefixtures("mcp_session") @@ -217,7 +236,7 @@ def test_show_buffer_no_truncation_under_cap(mcp_server: Server) -> None: assert seen.content_truncated_lines == 0 assert seen.content.rstrip("\n") == payload finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) @pytest.mark.parametrize( diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 8745060b..db88f52e 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -5581,6 +5581,15 @@ def test_paste_text(mcp_server: Server, mcp_pane: Pane) -> None: trailing newline forces execution, exercising the full paste->execute->output round-trip. """ + if mcp_server._get_version() == "3.3a": + with pytest.raises(ToolError, match=r"3\.4"): + paste_text( + text="echo PASTE_TEST_marker_xyz\n", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout + return result = paste_text( text="echo PASTE_TEST_marker_xyz\n", pane_id=mcp_pane.pane_id, @@ -5618,9 +5627,18 @@ def test_paste_text_does_not_leak_named_buffer( ``list-buffers`` on that prefix sees every MCP-owned buffer. The check is portable across every tmux version the CI matrix - tests (3.2a through master): ``list-buffers`` with a format string + tests (3.3a through master): ``list-buffers`` with a format string returns buffer names without any version-specific behavior. """ + if mcp_server._get_version() == "3.3a": + with pytest.raises(ToolError, match=r"3\.4"): + paste_text( + text="echo BUFFER_ISOLATION_test", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout + return paste_text( text="echo BUFFER_ISOLATION_test", pane_id=mcp_pane.pane_id, diff --git a/tests/test_tmux_features.py b/tests/test_tmux_features.py new file mode 100644 index 00000000..a218063d --- /dev/null +++ b/tests/test_tmux_features.py @@ -0,0 +1,155 @@ +"""Optional feature refusal leaves baseline MCP operations available.""" + +from __future__ import annotations + +import asyncio +import subprocess +import typing as t + +import pytest +from fastmcp.exceptions import ToolError +from libtmux import common, exc +from libtmux._internal import support + +from libtmux_mcp import _tmux_proc +from libtmux_mcp._utils import _tmux_argv +from libtmux_mcp.tools.buffer_tools import delete_buffer, load_buffer, show_buffer +from libtmux_mcp.tools.pane_tools.io import paste_text +from libtmux_mcp.tools.window_tools import select_layout + +if t.TYPE_CHECKING: + from libtmux import Pane, Server + + +@pytest.mark.parametrize("policy", [None, False, True, 0, 1, "2", 2.0]) +def test_backend_requires_optional_policy_before_spawning( + monkeypatch: pytest.MonkeyPatch, policy: object +) -> None: + """Matching package versions and truthy markers do not qualify a backend.""" + monkeypatch.setattr(common, "TMUX_OPTIONAL_FEATURE_POLICY_VERSION", policy) + + def forbidden(*args: object, **kwargs: object) -> t.NoReturn: + pytest.fail("an unqualified backend spawned a native child") + + monkeypatch.setattr(subprocess, "run", forbidden) + with pytest.raises(RuntimeError, match="optional feature policy version 2"): + _tmux_proc._run_tmux_sync_bounded(["tmux", "list-sessions"], timeout=1) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "command", + [["delete-buffer", "-b", "missing"], ["setb", "-b", "missing", "-n", "renamed"]], +) +def test_owned_helpers_enforce_named_buffer_identity( + mcp_server: Server, mcp_pane: Pane, asynchronous: bool, command: list[str] +) -> None: + """Owned child paths share the parent literal-command policy.""" + mcp_server.set_buffer("keep", buffer_name="user") + argv = _tmux_argv(mcp_server, *command) + + def invoke() -> object: + if asynchronous: + return asyncio.run(_tmux_proc._run_tmux_bounded(argv, timeout=5)) + return _tmux_proc._run_tmux_sync_bounded(argv, timeout=5, check=False) + + if not support.is_at_least(mcp_server._get_version(), "3.4"): + with pytest.raises(exc.VersionTooLow, match=r"3\.4"): + invoke() + else: + result = invoke() + assert ( + result[0] if isinstance(result, tuple) else t.cast(t.Any, result).returncode + ) != 0 + assert mcp_server.show_buffer(buffer_name="user") == "keep" + + +def test_delete_tool_preserves_user_buffer_when_named_target_is_missing( + mcp_server: Server, mcp_pane: Pane +) -> None: + """A missing agent name must never delete the most recent user buffer.""" + mcp_server.set_buffer("retained", buffer_name="human") + missing = "libtmux_mcp_" + "a" * 32 + "_absent" + expected = ( + "3.4" + if not support.is_at_least(mcp_server._get_version(), "3.4") + else "unknown buffer" + ) + with pytest.raises(ToolError, match=expected): + delete_buffer(missing, socket_name=mcp_server.socket_name) + assert mcp_server.show_buffer(buffer_name="human") == "retained" + + +def test_managed_paste_checks_cleanup_before_staging( + mcp_server: Server, mcp_pane: Pane, monkeypatch: pytest.MonkeyPatch +) -> None: + """Failed paste cleanup is eligible before a temporary native buffer exists.""" + mcp_server.set_buffer("retained", buffer_name="human") + entered: list[bool] = [] + + def failed_paste(*args: object, **kwargs: object) -> t.NoReturn: + entered.append(True) + msg = "intentional paste failure" + raise RuntimeError(msg) + + monkeypatch.setattr(type(mcp_pane), "paste_buffer", failed_paste) + expected = ( + "3.4" + if not support.is_at_least(mcp_server._get_version(), "3.4") + else "intentional paste failure" + ) + with pytest.raises(ToolError, match=expected): + paste_text( + "never-entered", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert entered == ([] if expected == "3.4" else [True]) + assert mcp_server.show_buffer(buffer_name="human") == "retained" + assert mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout == ["human"] + + +def test_empty_paste_and_inert_buffer_load_retain_the_core_floor( + mcp_server: Server, mcp_pane: Pane +) -> None: + """Empty paste has no buffer effect; staging and reading remain available.""" + assert "pasted" in paste_text( + "", pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name + ) + assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout + ref = load_buffer("baseline-retained", socket_name=mcp_server.socket_name) + assert ( + show_buffer(ref.buffer_name, socket_name=mcp_server.socket_name).content + == "baseline-retained" + ) + + +def test_owned_layout_dispatch_refuses_only_the_older_unsafe_error_path( + mcp_server: Server, mcp_pane: Pane +) -> None: + """Raw helper validation also covers the 3.3a layout error boundary.""" + window_id = mcp_pane.window.window_id + assert window_id is not None + argv = _tmux_argv(mcp_server, "select-layout", "-t", window_id, "not-a-layout") + if not support.is_at_least(mcp_server._get_version(), "3.4"): + with pytest.raises(ValueError, match="layout"): + _tmux_proc._run_tmux_sync_bounded(argv, timeout=5) + else: + assert ( + _tmux_proc._run_tmux_sync_bounded(argv, timeout=5, check=False).returncode + != 0 + ) + assert mcp_server.is_alive() + + +def test_layout_tool_reports_invalid_layout_without_ending_the_daemon( + mcp_server: Server, mcp_pane: Pane +) -> None: + """The advertised layout tool carries the parent validation boundary.""" + with pytest.raises(ToolError, match="layout"): + select_layout( + "not-a-layout", + window_id=mcp_pane.window.window_id, + socket_name=mcp_server.socket_name, + ) + assert mcp_server.is_alive() From 996fdfd086eeda8aa37bce69fc594a192f534425 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 02:50:17 -0500 Subject: [PATCH 3/9] Support(docs): Explain optional tmux operations why: Tool availability depends on its operation and the daemon's version. what: - Document individual buffer, output and layout requirements. - Explain the coordinated policy-2 source installation. --- docs/reference/compatibility.md | 40 ++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index e62467d5..e599adc1 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -35,6 +35,29 @@ semantics; this does not make those threads interruptible. Higher requirements for individual features are documented separately from the core minimum. Baseline polling does not require control mode. +## Optional operations + +Ordinary input, pane capture, output polling, completion channels and +`run_command` retain the 3.3a core floor. The selected daemon determines +eligibility for each operation. + +| Operation | Requirement | +| --- | --- | +| Named buffer deletion or rename | tmux 3.4+, before dispatch | +| Nonempty `paste_text` | tmux 3.4+, before loading its temporary buffer; failure cleanup needs exact named deletion | +| Empty `paste_text`, `load_buffer`, `show_buffer` and direct `paste_buffer` | Retain 3.3a | +| Literal `select-layout` on 3.3a | Validate the preset or serialized header before dispatch; ordinary valid layouts remain available | + +The backend must provide `libtmux.admission.require_commands`. +Startup and tools reject an older or invalid policy marker before work. Owned +subprocess helpers also enforce the parent's literal command policy; a package +source pin by itself cannot cover commands that bypass that dispatcher. + +`run_command` sends an authored command to a pane and observes completion. It +keeps 3.3a support independently of the parent library's captured foreground +`run-shell` output, which requires 3.5. Polling tools do not acquire control. +These optional refusals do not raise the whole MCP server's minimum. + ## Dependencies | Package | Required version | @@ -43,15 +66,16 @@ the core minimum. Baseline polling does not require control mode. | [FastMCP](https://github.com/jlowin/fastmcp) | >= 4.0.2, < 5.0.0 | | [regex](https://github.com/mrabarnett/mrab-regex) | >= 2024.11.6 | -The libtmux backend must publish `libtmux._internal.support`, which first ships in -libtmux 0.63.0; the dependency range names that release, so an installer never -selects the 0.62 series. Until libtmux 0.63.0 is released, this branch's uv -source override pins the implementation from -[Python core PR779](https://github.com/tmux-python/libtmux/pull/779) for +The libtmux backend must publish `libtmux._internal.support`, including +`require_commands`, which first ships in libtmux 0.63.0; the dependency range +names that release, so an installer never selects the 0.62 series. Until +libtmux 0.63.0 is released, this branch's uv source override pins the +implementation from +[Python feature PR780](https://github.com/tmux-python/libtmux/pull/780) for development; ordinary package installers ignore that override. Startup and tool -dispatch refuse a backend without that module. Release libtmux first, -then drop the source override and merge this change. Use -`uv sync --all-extras --dev` for the development pair. +dispatch refuse a backend without those names. Release libtmux first, then drop +the source override and merge this change. Use `uv sync --all-extras --dev` for +the development pair. ## MCP clients From f439de4526ce648e999fb8ed9ed4c24a46c41e22 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 02:50:17 -0500 Subject: [PATCH 4/9] Changelog(docs): Record optional tmux guards why: Callers need the operation-specific compatibility changes. what: - Record optional refusal, safe cleanup and retained baseline behavior. --- CHANGES | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES b/CHANGES index 2869aa57..8cc676eb 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,14 @@ _Notes on upcoming releases will be added here_ +### Optional feature requirements + +Nonempty `paste_text` and exact named-buffer deletion require tmux 3.4 before +native effects. Ordinary input, capture, polling, completion channels and +inert buffer staging retain 3.3a. Owned subprocesses enforce the coordinated +backend's optional feature policy version 2, including the older layout +error boundary. Startup and tool dispatch refuse an unqualified backend. + ### Compatibility Core operations require tmux 3.3a and libtmux 0.63.0 or newer. From f485c2e27c8e78ca351f7ac9ef69d84c7c57a774 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:34:22 -0500 Subject: [PATCH 5/9] mcp(fix): Refuse load_buffer where its buffer cannot be deleted load_buffer stages a named libtmux_mcp_* buffer. On a 3.3a daemon delete_buffer is refused (exact named deletion needs 3.4) and paste_buffer has no delete-after path, so every call left a buffer the agent could not remove, and tmux limits only automatic buffers, so repeated calls grew the daemon without bound. Contract 0002 asks to check staging before creating state whose cleanup cannot be guaranteed: give load_buffer the same 3.4 minimum paste_text has, so an older daemon refuses before the buffer exists. Tests: workflows that stage a buffer skip below 3.4; the dedicated test asserts the refusal and that only the user's buffer remains on 3.3a, and the stage-read-delete path on 3.4 and newer. Removing the minimum fails the 3.3a test with DID NOT RAISE. --- src/libtmux_mcp/tools/buffer_tools.py | 6 +++++- tests/test_buffer_tools.py | 29 +++++++++++++-------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 56e7ec95..bf0fa31d 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -186,6 +186,10 @@ def load_buffer( :attr:`~libtmux_mcp.models.BufferRef.buffer_name` on subsequent paste/show/delete calls. + Requires tmux 3.4: the staged buffer is named, and exact named-buffer + deletion, the only way to remove it, needs 3.4. Older daemons refuse + before creating the buffer. + **When to use this vs. paste_text:** ``load_buffer`` is the stage-then-fire path — you get a handle back and can inspect via ``show_buffer``, paste into multiple panes via ``paste_buffer``, @@ -218,7 +222,7 @@ def load_buffer( f.write(content) argv = _tmux_argv(server, "load-buffer", "-b", buffer_name, tmppath) try: - _run_tmux_sync_bounded(argv, timeout=5.0) + _run_tmux_sync_bounded(argv, timeout=5.0, minimum="3.4") except subprocess.TimeoutExpired as e: msg = f"load-buffer timeout after 5s for {buffer_name!r}" raise ExpectedToolError(msg) from e diff --git a/tests/test_buffer_tools.py b/tests/test_buffer_tools.py index 3a46ddec..1b2a3326 100644 --- a/tests/test_buffer_tools.py +++ b/tests/test_buffer_tools.py @@ -24,13 +24,15 @@ from libtmux.server import Server +def _require_named_buffers(server: Server) -> None: + """Skip workflows that stage a named buffer a 3.3a daemon cannot delete.""" + if not support.is_at_least(server._get_version(), "3.4"): + pytest.skip("load_buffer stages a named buffer and needs tmux 3.4 to delete it") + + def _assert_delete_policy(server: Server, name: str) -> None: - """Assert exact deletion or an older-daemon refusal during fixture cleanup.""" - if support.is_at_least(server._get_version(), "3.4"): - delete_buffer(name, socket_name=server.socket_name) - else: - with pytest.raises(ToolError, match=r"3\.4"): - delete_buffer(name, socket_name=server.socket_name) + """Delete an agent buffer exactly.""" + delete_buffer(name, socket_name=server.socket_name) @pytest.mark.parametrize("name", ["my-buffer", "clipboard.v2", "a", "x" * 64]) @@ -73,6 +75,7 @@ def test_load_buffer_allocates_unique_name(mcp_server: Server) -> None: The UUID nonce in the allocated name prevents collisions even when the caller reuses the same logical name. """ + _require_named_buffers(mcp_server) a = load_buffer( content="first", logical_name="clipboard", @@ -98,6 +101,7 @@ def test_load_buffer_allocates_unique_name(mcp_server: Server) -> None: @pytest.mark.usefixtures("mcp_session") def test_buffer_round_trip(mcp_server: Server) -> None: """Load -> show -> delete round-trip preserves content exactly.""" + _require_named_buffers(mcp_server) payload = "line1\nline2\nline3" ref = load_buffer( content=payload, @@ -111,15 +115,6 @@ def test_buffer_round_trip(mcp_server: Server) -> None: # comparison. assert seen.content.rstrip("\n") == payload - if not support.is_at_least(mcp_server._get_version(), "3.4"): - _assert_delete_policy(mcp_server, ref.buffer_name) - assert ( - show_buffer( - ref.buffer_name, socket_name=mcp_server.socket_name - ).content.rstrip("\n") - == payload - ) - return result = delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) assert "deleted" in result @@ -141,6 +136,7 @@ def test_paste_buffer_requires_mcp_namespace( def test_paste_buffer_into_pane(mcp_server: Server, mcp_pane: Pane) -> None: """``paste_buffer`` pastes an MCP buffer into the target pane.""" + _require_named_buffers(mcp_server) ref = load_buffer( content="echo PASTE_BUFFER_MARKER", logical_name="smoke", @@ -174,6 +170,7 @@ def test_show_buffer_tail_preserves_on_truncation(mcp_server: Server) -> None: ``True``, and ``content_truncated_lines`` reports how many were dropped so the caller can re-request with ``max_lines=None``. """ + _require_named_buffers(mcp_server) payload = "\n".join(f"line-{i}" for i in range(20)) ref = load_buffer( content=payload, @@ -198,6 +195,7 @@ def test_show_buffer_tail_preserves_on_truncation(mcp_server: Server) -> None: @pytest.mark.usefixtures("mcp_session") def test_show_buffer_full_read_when_max_lines_none(mcp_server: Server) -> None: """``max_lines=None`` disables truncation for full-buffer recovery.""" + _require_named_buffers(mcp_server) payload = "\n".join(f"line-{i}" for i in range(50)) ref = load_buffer( content=payload, @@ -220,6 +218,7 @@ def test_show_buffer_full_read_when_max_lines_none(mcp_server: Server) -> None: @pytest.mark.usefixtures("mcp_session") def test_show_buffer_no_truncation_under_cap(mcp_server: Server) -> None: """Small buffers are returned verbatim with truncation flags off.""" + _require_named_buffers(mcp_server) payload = "one\ntwo\nthree" ref = load_buffer( content=payload, From a1a5b527a26dde663da81d48ba00f8843f9425ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:34:22 -0500 Subject: [PATCH 6/9] Tests(fix): Replace the policy-marker test with the command-policy check libtmux no longer exposes an integer policy marker; the owned helpers need libtmux._internal.support.require_commands. A backend without it is refused before any native child starts, with LibTmuxException. The staging test now asserts that no buffer is created on a daemon that cannot delete it. --- tests/test_tmux_features.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/tests/test_tmux_features.py b/tests/test_tmux_features.py index a218063d..2b89e863 100644 --- a/tests/test_tmux_features.py +++ b/tests/test_tmux_features.py @@ -8,7 +8,7 @@ import pytest from fastmcp.exceptions import ToolError -from libtmux import common, exc +from libtmux import exc from libtmux._internal import support from libtmux_mcp import _tmux_proc @@ -21,18 +21,17 @@ from libtmux import Pane, Server -@pytest.mark.parametrize("policy", [None, False, True, 0, 1, "2", 2.0]) -def test_backend_requires_optional_policy_before_spawning( - monkeypatch: pytest.MonkeyPatch, policy: object +def test_backend_requires_command_policy_before_spawning( + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Matching package versions and truthy markers do not qualify a backend.""" - monkeypatch.setattr(common, "TMUX_OPTIONAL_FEATURE_POLICY_VERSION", policy) + """A libtmux without the command policy cannot qualify the owned helpers.""" + monkeypatch.delattr(support, "require_commands") def forbidden(*args: object, **kwargs: object) -> t.NoReturn: pytest.fail("an unqualified backend spawned a native child") monkeypatch.setattr(subprocess, "run", forbidden) - with pytest.raises(RuntimeError, match="optional feature policy version 2"): + with pytest.raises(exc.LibTmuxException, match="version check is unavailable"): _tmux_proc._run_tmux_sync_bounded(["tmux", "list-sessions"], timeout=1) @@ -109,19 +108,33 @@ def failed_paste(*args: object, **kwargs: object) -> t.NoReturn: assert mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout == ["human"] -def test_empty_paste_and_inert_buffer_load_retain_the_core_floor( - mcp_server: Server, mcp_pane: Pane -) -> None: - """Empty paste has no buffer effect; staging and reading remain available.""" +def test_empty_paste_retains_the_core_floor(mcp_server: Server, mcp_pane: Pane) -> None: + """Empty paste has no buffer effect on any supported release.""" assert "pasted" in paste_text( "", pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name ) assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout + + +def test_load_buffer_stages_only_where_it_can_be_deleted( + mcp_server: Server, mcp_pane: Pane +) -> None: + """A 3.3a daemon creates no buffer; 3.4 stages one the caller can delete.""" + mcp_server.set_buffer("retained", buffer_name="human") + if not support.is_at_least(mcp_server._get_version(), "3.4"): + with pytest.raises(ToolError, match=r"3\.4"): + load_buffer("never-staged", socket_name=mcp_server.socket_name) + assert mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout == [ + "human" + ] + return ref = load_buffer("baseline-retained", socket_name=mcp_server.socket_name) assert ( show_buffer(ref.buffer_name, socket_name=mcp_server.socket_name).content == "baseline-retained" ) + delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + assert mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout == ["human"] def test_owned_layout_dispatch_refuses_only_the_older_unsafe_error_path( From d727e33bcdbf2555f916563a20d0822797252576 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:34:22 -0500 Subject: [PATCH 7/9] Support(docs): Correct the buffer staging requirement load_buffer is no longer called inert: it stages a named buffer and needs 3.4, like exact deletion. Replace the policy-marker wording with the require_commands requirement. --- docs/reference/compatibility.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index e599adc1..498f33f5 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -43,15 +43,15 @@ eligibility for each operation. | Operation | Requirement | | --- | --- | -| Named buffer deletion or rename | tmux 3.4+, before dispatch | +| `load_buffer`, which stages a named buffer, and named buffer deletion or rename | tmux 3.4+, before dispatch; a 3.3a daemon creates no buffer | | Nonempty `paste_text` | tmux 3.4+, before loading its temporary buffer; failure cleanup needs exact named deletion | -| Empty `paste_text`, `load_buffer`, `show_buffer` and direct `paste_buffer` | Retain 3.3a | +| Empty `paste_text`, `show_buffer` of an existing buffer and direct `paste_buffer` | Retain 3.3a | | Literal `select-layout` on 3.3a | Validate the preset or serialized header before dispatch; ordinary valid layouts remain available | -The backend must provide `libtmux.admission.require_commands`. -Startup and tools reject an older or invalid policy marker before work. Owned -subprocess helpers also enforce the parent's literal command policy; a package -source pin by itself cannot cover commands that bypass that dispatcher. +The backend must provide `libtmux._internal.support.require_commands`; startup and +tools refuse a libtmux without it before work. Owned subprocess helpers also +enforce the parent's literal command policy, which a package pin by itself +cannot cover for commands that bypass libtmux's dispatcher. `run_command` sends an authored command to a pane and observes completion. It keeps 3.3a support independently of the parent library's captured foreground From 586a33d1320f0a8e896e2e617bee242aacea09d7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 07:34:22 -0500 Subject: [PATCH 8/9] Changelog(docs): Record the load_buffer requirement Note that load_buffer joins paste_text and exact deletion at 3.4 and that the backend contract is libtmux._internal.support.require_commands. Keep the release note separate from runtime changes. --- CHANGES | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index 8cc676eb..4a344d01 100644 --- a/CHANGES +++ b/CHANGES @@ -8,11 +8,12 @@ _Notes on upcoming releases will be added here_ ### Optional feature requirements -Nonempty `paste_text` and exact named-buffer deletion require tmux 3.4 before -native effects. Ordinary input, capture, polling, completion channels and -inert buffer staging retain 3.3a. Owned subprocesses enforce the coordinated -backend's optional feature policy version 2, including the older layout -error boundary. Startup and tool dispatch refuse an unqualified backend. +`load_buffer`, nonempty `paste_text` and exact named-buffer deletion require +tmux 3.4 before native effects, because a named buffer a 3.3a daemon cannot +delete would accumulate there. Ordinary input, capture, polling and completion +channels retain 3.3a. Owned subprocesses enforce libtmux's literal command +policy, including the older layout error boundary. Startup and tool dispatch +refuse a libtmux without `libtmux._internal.support.require_commands`. ### Compatibility From ba573fee70d83fb52256290b32fb6deb2dc9bfaa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 11:25:59 -0500 Subject: [PATCH 9/9] Tests(fix): Pin the exact-3.7 name guard through the owned fence The owned helpers run libtmux's command policy, so window and session names holding ':' or '.' are refused on 3.7 and pass on 3.7a with no extra code. Removing the require_commands call in the fence fails the new test. --- tests/test_tmux_features.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_tmux_features.py b/tests/test_tmux_features.py index 2b89e863..1e1c3d3b 100644 --- a/tests/test_tmux_features.py +++ b/tests/test_tmux_features.py @@ -166,3 +166,20 @@ def test_layout_tool_reports_invalid_layout_without_ending_the_daemon( socket_name=mcp_server.socket_name, ) assert mcp_server.is_alive() + + +@pytest.mark.parametrize( + "command", + [ + ["new-window", "-n", "a.b"], + ["rename-window", "a:b"], + ["new-session", "-s", "a.b"], + ], +) +def test_owned_helpers_inherit_the_exact_37_name_guard( + monkeypatch: pytest.MonkeyPatch, command: list[str] +) -> None: + """The owned fence runs libtmux's command policy, names included.""" + with pytest.raises(exc.VersionTooLow, match="invalid window or session name"): + _tmux_proc._fence(["tmux", *command], "3.7", live=True) + _tmux_proc._fence(["tmux", *command], "3.7a", live=True)