From d176e5d52826b9931f9c96b321c4f3a30dffdd1e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 12 May 2026 17:40:44 -0500 Subject: [PATCH 1/6] py(deps[libtmux]): bump floor to 0.56.0 (#46) why: libtmux 0.56.0 ships over fifty new public methods that close the long-tail gap between the tmux(1) surface and the Python wrapper. The MCP currently drops down to raw Server.cmd(...) for over a dozen commands that the new release now covers; bumping the floor unlocks the wrapper adoption in the follow-up commits on this branch. what: - pyproject.toml: libtmux>=0.55.1,<1.0 -> libtmux>=0.56.0,<1.0 - pyproject.toml: add libtmux to [tool.uv.exclude-newer-package] so the 3-day cooldown does not block fresh releases for every contributor's uv sync. Mirrors the gp-libs / gp-sphinx entries. - uv.lock: libtmux 0.55.1 -> 0.56.0 - CHANGES: ### Dependencies entry naming the new wrappers this floor change unlocks (Pane.respawn, Pane.copy_mode, Pane.pipe, Pane.swap, Pane.paste_buffer, Pane.clear_history, Pane.display_message, Server.delete_buffer, Session navigation). --- CHANGES | 4 ++++ pyproject.toml | 3 ++- uv.lock | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 393d3e25..19b0e198 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,10 @@ _Notes on upcoming releases will be added here_ +### Dependencies + +**Minimum `libtmux>=0.56.0`** (was `>=0.55.1`). Unlocks the new tmux-command wrappers shipped in libtmux 0.56.0 — {meth}`~libtmux.Pane.respawn`, {meth}`~libtmux.Pane.copy_mode`, {meth}`~libtmux.Pane.pipe`, {meth}`~libtmux.Pane.swap`, {meth}`~libtmux.Pane.paste_buffer`, {meth}`~libtmux.Pane.clear_history`, {meth}`~libtmux.Pane.display_message`, {meth}`~libtmux.Server.delete_buffer`, and the {meth}`~libtmux.Session.next_window` / {meth}`~libtmux.Session.previous_window` / {meth}`~libtmux.Session.last_window` trio — so the MCP no longer falls back to raw `cmd()` calls for tmux commands the upstream API now covers. (#46) + ## libtmux-mcp 0.1.0a6 (2026-05-09) libtmux-mcp 0.1.0a6 is the activation and registration cleanup release. It makes the server much easier for MCP clients to discover from ordinary "pane", "window", and "session" prompts, standardizes new setup docs around the `tmux` registration slug, and adds migration guidance for existing `libtmux` registrations. Existing installs keep working; the release changes defaults and documentation so new installs line up with the tool prefix users actually see. diff --git a/pyproject.toml b/pyproject.toml index 15bd2803..e1ab0683 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ include = [ ] dependencies = [ - "libtmux>=0.55.1,<1.0", + "libtmux>=0.56.0,<1.0", "fastmcp>=3.2.4,<4.0.0", ] @@ -118,6 +118,7 @@ build-backend = "hatchling.build" # from any `exclude-newer` constraint (global or per-user) without # committing a date that would age into the lockfile. Mirrors the # pattern at vcspull/pyproject.toml for libvcs. +libtmux = false gp-libs = false gp-furo-theme = false gp-sphinx = false diff --git a/uv.lock b/uv.lock index bde39d81..f5b0d39b 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P3D" [options.exclude-newer-package] +libtmux = false gp-sphinx = false sphinx-autodoc-sphinx = false sphinx-autodoc-api-style = false @@ -1187,7 +1188,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=3.2.4,<4.0.0" }, - { name = "libtmux", specifier = ">=0.55.1,<1.0" }, + { name = "libtmux", specifier = ">=0.56.0,<1.0" }, ] [package.metadata.requires-dev] From 06afaa6fb58b8e2499c6f2b78c266cf0be739184 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 12 May 2026 17:42:37 -0500 Subject: [PATCH 2/6] Pane(refactor[respawn]): adopt Pane.respawn from libtmux 0.56 (#46) why: libtmux 0.56.0 ships Pane.respawn with the exact argv shape the stopgap predicted; the manual cmd("respawn-pane", *argv) call was a placeholder waiting on the release. what: - src/libtmux_mcp/tools/pane_tools/lifecycle.py: replace the manual argv builder + cmd("respawn-pane", *argv) + if result.stderr block with pane.respawn(kill=, start_directory=, environment=, shell=). - Delete the multi-line stopgap comment that prescribed exactly this swap. Pane.respawn raises LibTmuxException directly, which the handle_tool_errors decorator already translates to ToolError. --- src/libtmux_mcp/tools/pane_tools/lifecycle.py | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/src/libtmux_mcp/tools/pane_tools/lifecycle.py b/src/libtmux_mcp/tools/pane_tools/lifecycle.py index ee82a160..4eb2e0a9 100644 --- a/src/libtmux_mcp/tools/pane_tools/lifecycle.py +++ b/src/libtmux_mcp/tools/pane_tools/lifecycle.py @@ -150,30 +150,12 @@ def respawn_pane( "Use a manual tmux command if intended." ) raise ToolError(msg) - # Stopgap: ``libtmux>=0.55.1`` has no ``Pane.respawn()`` yet — the - # wrapper exists on the upstream ``tmux-parity`` branch (see - # ``libtmux/pane.py:respawn``) and mirrors this argv shape: ``-k``, - # ``-c ``, repeated ``-e=`` (single-arg form, NOT - # split ``-e KEY=VAL`` — tmux's args parser accepts both but - # upstream emits the joined form), then optional trailing shell. - # When the release line picks it up, swap ``pane.cmd("respawn-pane", - # *argv)`` for ``pane.respawn(kill=kill, start_directory= - # start_directory, environment=environment, shell=shell)`` and drop - # the stderr branch — ``Pane.respawn`` raises ``LibTmuxException``. - argv: list[str] = [] - if kill: - argv.append("-k") - if start_directory is not None: - argv.extend(["-c", start_directory]) - if environment: - argv.extend(f"-e{k}={v}" for k, v in environment.items()) - if shell is not None: - argv.append(shell) - result = pane.cmd("respawn-pane", *argv) - if result.stderr: - stderr = " ".join(result.stderr).strip() - msg = f"tmux respawn-pane failed: {stderr}" - raise ToolError(msg) + pane.respawn( + kill=kill, + start_directory=start_directory, + environment=environment, + shell=shell, + ) # Pick up fresh pane_pid and any command/path updates; tmux does # not invalidate the underlying object on respawn. pane.refresh() From ecd053dea64ccd61ed0b259d4f2ec572aae3f710 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 12 May 2026 17:44:29 -0500 Subject: [PATCH 3/6] Pane(refactor): adopt copy_mode / pipe / clear_history wrappers (#46) why: libtmux 0.56.0 exposes typed wrappers for copy-mode entry, pipe-pane configuration, and clear-history. The MCP was hand- rolling argv tuples for all three; the wrappers replace that boilerplate while keeping behaviour identical. what: - tools/pane_tools/copy_mode.py: pane.cmd("copy-mode") -> pane.copy_mode(). scroll-up + cancel send-keys -X invocations go through Pane.send_keys(copy_mode_cmd=..., repeat=..., enter=False) so the -X flag and -N repeat count are emitted by libtmux's argv builder. - tools/pane_tools/pipe.py: pane.cmd("pipe-pane", ...) -> pane.pipe(...) for both the stop (no command) and start (cat redirect command) paths. - tools/pane_tools/io.py:241: pane.cmd("clear-history") -> pane.clear_history(). The send-keys -R line above stays as a raw cmd() because Pane.send_keys would emit an extra empty key alongside the reset flag, and Pane.reset() is still broken upstream (libtmux #650). --- src/libtmux_mcp/tools/pane_tools/copy_mode.py | 18 +++++++----------- src/libtmux_mcp/tools/pane_tools/io.py | 11 +++++++---- src/libtmux_mcp/tools/pane_tools/pipe.py | 4 ++-- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/libtmux_mcp/tools/pane_tools/copy_mode.py b/src/libtmux_mcp/tools/pane_tools/copy_mode.py index ed776f63..6c9627aa 100644 --- a/src/libtmux_mcp/tools/pane_tools/copy_mode.py +++ b/src/libtmux_mcp/tools/pane_tools/copy_mode.py @@ -55,16 +55,13 @@ def enter_copy_mode( session_id=session_id, window_id=window_id, ) - # libtmux's Pane.cmd injects ``-t pane.pane_id``; passing it again - # produced the duplicated ``-t %X -t %X`` shape tmux silently accepted. - pane.cmd("copy-mode") + pane.copy_mode() if scroll_up is not None and scroll_up > 0: - pane.cmd( - "send-keys", - "-X", - "-N", - str(scroll_up), - "scroll-up", + pane.send_keys( + "", + copy_mode_cmd="scroll-up", + repeat=scroll_up, + enter=False, ) pane.refresh() return _serialize_pane(pane) @@ -109,7 +106,6 @@ def exit_copy_mode( session_id=session_id, window_id=window_id, ) - # See enter_copy_mode: Pane.cmd injects ``-t pane.pane_id`` already. - pane.cmd("send-keys", "-X", "cancel") + pane.send_keys("", copy_mode_cmd="cancel", enter=False) pane.refresh() return _serialize_pane(pane) diff --git a/src/libtmux_mcp/tools/pane_tools/io.py b/src/libtmux_mcp/tools/pane_tools/io.py index 27e2ccea..58af7f8f 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -233,12 +233,15 @@ def clear_pane( session_id=session_id, window_id=window_id, ) - # Split into two cmd() calls — pane.reset() in libtmux <= 0.55.0 sends - # `send-keys -R \; clear-history` as one call, but subprocess doesn't - # interpret \; as a tmux command separator so clear-history never runs. + # Two separate calls — pane.reset() in libtmux 0.56.0 still sends + # `send-keys -R \; clear-history` as one call but subprocess doesn't + # interpret \; as a tmux command separator, so clear-history never + # runs. The bare `-R` send is left as a raw cmd() because + # Pane.send_keys requires a cmd string and would emit an extra + # empty key alongside the reset flag. # See: https://github.com/tmux-python/libtmux/issues/650 pane.cmd("send-keys", "-R") - pane.cmd("clear-history") + pane.clear_history() return f"Pane cleared: {pane.pane_id}" diff --git a/src/libtmux_mcp/tools/pane_tools/pipe.py b/src/libtmux_mcp/tools/pane_tools/pipe.py index 6f43a775..d443ac73 100644 --- a/src/libtmux_mcp/tools/pane_tools/pipe.py +++ b/src/libtmux_mcp/tools/pane_tools/pipe.py @@ -75,7 +75,7 @@ def pipe_pane( ) if output_path is None: - pane.cmd("pipe-pane") + pane.pipe() return f"Piping stopped for pane {pane.pane_id}" if not output_path.strip(): @@ -83,5 +83,5 @@ def pipe_pane( raise ToolError(msg) redirect = ">>" if append else ">" - pane.cmd("pipe-pane", f"cat {redirect} {shlex.quote(output_path)}") + pane.pipe(f"cat {redirect} {shlex.quote(output_path)}") return f"Piping pane {pane.pane_id} to {output_path}" From acf10a455f27b00e717748178f81444572acbf21 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 12 May 2026 17:46:04 -0500 Subject: [PATCH 4/6] Pane,Server(refactor[buffer]): adopt paste_buffer + delete_buffer (#46) why: libtmux 0.56.0 ships typed wrappers for paste-buffer and delete-buffer. The MCP was assembling argv lists by hand and redundantly injecting -t pane_id (libtmux's Pane.cmd already does that, leaving the duplicate behind as latent boilerplate). what: - tools/buffer_tools.py: paste_buffer tool now calls pane.paste_buffer(buffer_name=, bracket=) instead of building -b/-p/-t argv. The duplicate -t injection is gone. - tools/pane_tools/io.py: paste_text's mid-flight paste uses pane.paste_buffer(buffer_name=, bracket=, delete_after=True); the cleanup leg uses server.delete_buffer(buffer_name=). - server.py: _gc_mcp_buffers cleanup loop now calls server.delete_buffer(buffer_name=) directly. The list-buffers read keeps -F because Server.list_buffers() returns tmux's default formatted lines, not raw names. --- src/libtmux_mcp/server.py | 2 +- src/libtmux_mcp/tools/buffer_tools.py | 6 +----- src/libtmux_mcp/tools/pane_tools/io.py | 13 +++++-------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/libtmux_mcp/server.py b/src/libtmux_mcp/server.py index bff7c8a1..8bf3d2ff 100644 --- a/src/libtmux_mcp/server.py +++ b/src/libtmux_mcp/server.py @@ -261,7 +261,7 @@ def _gc_mcp_buffers(cache: t.Mapping[_ServerCacheKey, Server]) -> None: if not name.startswith(_MCP_BUFFER_PREFIX): continue try: - server.cmd("delete-buffer", "-b", name) + server.delete_buffer(buffer_name=name) except Exception as err: logger.debug("buffer GC: delete-buffer %s failed: %s", name, err) diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index 28f9f413..e702c7fb 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -268,11 +268,7 @@ def paste_buffer( session_id=session_id, window_id=window_id, ) - paste_args: list[str] = ["-b", cname] - if bracket: - paste_args.append("-p") - paste_args.extend(["-t", pane.pane_id or ""]) - pane.cmd("paste-buffer", *paste_args) + pane.paste_buffer(buffer_name=cname, bracket=bracket) return f"Buffer {cname!r} pasted to pane {pane.pane_id}" diff --git a/src/libtmux_mcp/tools/pane_tools/io.py b/src/libtmux_mcp/tools/pane_tools/io.py index 58af7f8f..15870aa3 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -328,13 +328,10 @@ def paste_text( msg = f"load-buffer failed: {stderr or e}" raise ToolError(msg) from e - # Paste from the named buffer. -d deletes only that named buffer, - # leaving any unnamed user buffer intact. - paste_args = ["-b", buffer_name, "-d"] - if bracket: - paste_args.append("-p") # bracketed paste mode - paste_args.extend(["-t", pane.pane_id or ""]) - pane.cmd("paste-buffer", *paste_args) + # Paste from the named buffer. ``delete_after=True`` (``-d``) + # deletes only that named buffer, leaving any unnamed user + # buffer intact. + pane.paste_buffer(buffer_name=buffer_name, bracket=bracket, delete_after=True) finally: if tmppath is not None: pathlib.Path(tmppath).unlink(missing_ok=True) @@ -342,6 +339,6 @@ def paste_text( # deletes it), but if paste-buffer failed before -d took effect # we leak an entry in the tmux server. Best-effort delete. with contextlib.suppress(Exception): - server.cmd("delete-buffer", "-b", buffer_name) + server.delete_buffer(buffer_name=buffer_name) return f"Text pasted to pane {pane.pane_id}" From 99921420993e71402cd3513dec36ae65f6b0f650 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 12 May 2026 17:47:46 -0500 Subject: [PATCH 5/6] Session(refactor): adopt Session.{next,previous,last}_window (#46) why: libtmux 0.56.0 ships Session.next_window / previous_window / last_window wrappers that inject `-t $session_id`, return the new active Window, and raise LibTmuxException on stderr. The MCP's directional-navigation block was hand-rolling all three concerns. what: - tools/session_tools.py: collapse the next/previous/last-window cmd() dispatch into a method-name lookup. Each wrapper returns the new active Window, so the manual `if proc.stderr:` branch and the post-call `session.active_window` re-fetch are gone. - tests/test_session_tools.py: the "no prior window" regression test now matches "no last window" (tmux's actual stderr surfaced via LibTmuxException -> ToolError) instead of the old "last-window" subcommand-name substring. --- src/libtmux_mcp/tools/session_tools.py | 28 ++++++++++---------------- tests/test_session_tools.py | 12 +++++------ 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index b2398e50..ca841ab1 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -305,28 +305,22 @@ def select_window( window.select() return _serialize_window(window) - # Directional navigation: use the dedicated tmux subcommands so that - # libtmux's Session.cmd injects `-t $session_id` and the navigation - # stays scoped to this session (a bare `-t +` resolves against the - # attached client, not the target session). + # Directional navigation. Each Session method injects `-t + # $session_id`, returns the new active Window, and raises + # LibTmuxException on stderr — so the dispatch reduces to a + # straight lookup with no manual stderr handling. session = _resolve_session(server, session_name=session_name, session_id=session_id) - _CMD_MAP = { - "next": "next-window", - "previous": "previous-window", - "last": "last-window", + _NAV = { + "next": session.next_window, + "previous": session.previous_window, + "last": session.last_window, } assert direction is not None - subcommand = _CMD_MAP.get(direction) - if subcommand is None: + fn = _NAV.get(direction) + if fn is None: msg = f"Invalid direction: {direction!r}. Valid: next, previous, last" raise ToolError(msg) - proc = session.cmd(subcommand) - if proc.stderr: - stderr = " ".join(proc.stderr).strip() - msg = f"tmux {subcommand} failed: {stderr}" - raise ToolError(msg) - - active_window = session.active_window + active_window = fn() return _serialize_window(active_window) diff --git a/tests/test_session_tools.py b/tests/test_session_tools.py index 0fcc8afe..fe3a2848 100644 --- a/tests/test_session_tools.py +++ b/tests/test_session_tools.py @@ -271,15 +271,15 @@ def test_select_window_last_on_single_window_session_raises( ) -> None: """select_window last with no prior window must surface the tmux error. - Regression guard: session.cmd("last-window") on a session that has - never had a previously-active window emits "no last window" on - stderr, but the tool previously discarded the return value and - returned the unchanged active window as if the navigation had - worked. + Regression guard: ``Session.last_window`` on a session that has + never had a previously-active window raises ``LibTmuxException`` + with tmux's "no last window" stderr; the tool previously discarded + the return value and returned the unchanged active window as if + the navigation had worked. """ # The fixture session is freshly created: there is no previously- # active window for last-window to jump back to. - with pytest.raises(ToolError, match="last-window"): + with pytest.raises(ToolError, match="no last window"): select_window( direction="last", session_name=mcp_session.session_name, From 337f1eabb6639e2efaacdc08655b6c5e005a9351 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 12 May 2026 17:50:28 -0500 Subject: [PATCH 6/6] Pane(refactor): adopt Pane.swap + Pane.display_message + Pane.select (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: libtmux 0.56.0 ships typed wrappers for swap-pane and display- message that subsume the MCP's hand-built argv shapes. Pane.select was already available pre-0.56; this commit folds in its adoption alongside the new wrappers as part of the same layout/meta cleanup pass. what: - tools/pane_tools/layout.py:174: server.cmd("select-pane", target=target_pane.pane_id) -> target_pane.select(). - tools/pane_tools/layout.py:221: server.cmd("swap-pane", ...) -> source.swap(target=target) after resolving the target_pane_id to a Pane object. - tools/pane_tools/meta.py:66: pane.cmd("display-message", "-p", fmt) -> pane.display_message(fmt, get_text=True). Same join logic over the returned list[str]. - tools/pane_tools/meta.py:162: same display_message wrapper applied to the snapshot_pane multi-format read. - tools/pane_tools/layout.py:74: the window-zoomed-flag read stays on window.cmd("display-message", ...) — window_zoomed_flag isn't a declared attribute on libtmux's Window in 0.56.0 and Window has no display_message wrapper yet. Comment explains the gap so the next pass can pick it up when upstream lands either surface. --- src/libtmux_mcp/tools/pane_tools/layout.py | 9 ++++++--- src/libtmux_mcp/tools/pane_tools/meta.py | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/libtmux_mcp/tools/pane_tools/layout.py b/src/libtmux_mcp/tools/pane_tools/layout.py index 40a689e2..be4c74b1 100644 --- a/src/libtmux_mcp/tools/pane_tools/layout.py +++ b/src/libtmux_mcp/tools/pane_tools/layout.py @@ -71,6 +71,9 @@ def resize_pane( ) if zoom is not None: window = pane.window + # ``window_zoomed_flag`` isn't declared on the libtmux Window + # dataclass in 0.56.0, and Window has no ``display_message`` + # wrapper — keep the raw cmd() until upstream surfaces one. result = window.cmd("display-message", "-p", "#{window_zoomed_flag}") is_zoomed = bool(result.stdout) and result.stdout[0] == "1" if zoom and not is_zoomed: @@ -171,7 +174,7 @@ def select_pane( idx = panes.index(active) step = 1 if direction == "next" else -1 target_pane = panes[(idx + step) % len(panes)] - server.cmd("select-pane", target=target_pane.pane_id) + target_pane.select() # Query the active pane ID directly from tmux to avoid stale cache target = window.window_id or "" @@ -216,8 +219,8 @@ def swap_pane( server = _get_server(socket_name=socket_name) # Validate both panes exist source = _resolve_pane(server, pane_id=source_pane_id) - _resolve_pane(server, pane_id=target_pane_id) + target = _resolve_pane(server, pane_id=target_pane_id) - server.cmd("swap-pane", "-s", source_pane_id, "-t", target_pane_id) + source.swap(target=target) source.refresh() return _serialize_pane(source) diff --git a/src/libtmux_mcp/tools/pane_tools/meta.py b/src/libtmux_mcp/tools/pane_tools/meta.py index 8bde5359..1ef8467d 100644 --- a/src/libtmux_mcp/tools/pane_tools/meta.py +++ b/src/libtmux_mcp/tools/pane_tools/meta.py @@ -63,8 +63,8 @@ def display_message( session_id=session_id, window_id=window_id, ) - result = pane.cmd("display-message", "-p", format_string) - return "\n".join(result.stdout) if result.stdout else "" + stdout = pane.display_message(format_string, get_text=True) + return "\n".join(stdout) if stdout else "" @handle_tool_errors @@ -159,8 +159,8 @@ def snapshot_pane( "#{pane_tty}", ] fmt = _SEP.join(_FMT_VARS) - result = pane.cmd("display-message", "-p", fmt) - raw = result.stdout[0] if result.stdout else "" + stdout = pane.display_message(fmt, get_text=True) + raw = stdout[0] if stdout else "" # Pad defensively to guarantee one slot per format var even if tmux # drops an unknown variable on older versions. parts = (raw.split(_SEP) + [""] * len(_FMT_VARS))[: len(_FMT_VARS)]