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
27 changes: 27 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,33 @@ Workflow actions moved to their current major releases: `actions/checkout` v7,
and `dorny/paths-filter` v4. Workflow behavior is unchanged, though setup-uv no
longer prunes the uv cache, so the first run after this repopulates it.

**Minimum `ruff>=0.16.0`** (was unpinned)

The `dev` and `lint` dependency groups now require ruff 0.16.0 or newer, so
contributors and CI see the same diagnostics rather than whichever ruff each
machine resolved. From this release on, `ruff format` also formats Python code
blocks inside Markdown, so `uv run ruff format .` reaches the README and the
`docs/` tree. Neither the lint nor the format change rewrites anything here.
(#113)

**ruff's curated default rule set is now enabled**

`[tool.ruff.lint]` layers this project's linters on with `extend-select` instead
of `select`. An explicit `select` replaces ruff's default set rather than adding
to it, so the project had been silently opting out of every rule it did not name
itself; enabled rules go from 351 to 565. The findings that surfaced are fixed —
`re.DOTALL` spelled out in place of `re.S`, `str.removesuffix` and `min()` in
`scripts/mcp_swap.py`, the server instruction blob built as an f-string, and the
wrapped marker fixtures parenthesized so a dropped comma can no longer pass for
a deliberate join. `scripts/mcp_swap.py` is now executable and its shebang points
at `uv run --script`, so running it directly honours the PEP 723 metadata instead
of failing to import `tomlkit` under a bare `python3`. Two rules are scoped
ignores with their reasons recorded in
`pyproject.toml`: `exec` in `docs/conf.py`, which is how Sphinx reads version
metadata, and the `except Exception` boundaries in the error middleware, the
batch runners, the best-effort tmux probes, and `mcp_swap`'s config rollback.
(#113)

## libtmux-mcp 0.1.0a19 (2026-07-25)

libtmux-mcp 0.1.0a19 makes {tooliconl}`wait-for-text` see the output it was asked to watch for, and puts a ceiling under every wait. The tool anchored one row below the cursor's position at entry, which on a quiescent pane is exactly where the next line lands, so the case it exists for — output you did not author, a daemon printing a single `ready` line — could not match at all. Waits are now bounded by a server ceiling, cancellable without orphaning their tmux child, and report an `outcome` that distinguishes a command that never ran from output that did not match from a pager owning the pane. The wait API changes shape in the process: `pattern` becomes `patterns`, `stop` markers end a wait early, and `wait_for_content_change` is removed in favour of `wait_for_text(patterns=null)`.
Expand Down
33 changes: 30 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ dev = [
"coverage",
"pytest-cov",
# Lint
"ruff",
"ruff>=0.16.0",
"mypy",
]

Expand All @@ -103,7 +103,7 @@ coverage =[
]
lint = [
"typing-extensions; python_version < '3.11'",
"ruff",
"ruff>=0.16.0",
"mypy",
]

Expand Down Expand Up @@ -188,7 +188,10 @@ exclude_lines = [
target-version = "py310"

[tool.ruff.lint]
select = [
# `select` is deliberately unset: ruff 0.16 enables a curated default rule
# set, and an explicit `select` would replace it rather than extend it.
# `extend-select` layers this project's additional linters on top.
extend-select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
Expand Down Expand Up @@ -237,6 +240,30 @@ convention = "numpy"

[tool.ruff.lint.per-file-ignores]
"*/__init__.py" = ["F401"]
# The Sphinx conf idiom: `exec` the package's `__about__.py` to read
# version metadata without importing the package (and its dependencies)
# into the docs build. The path is a repo-local file, not user input.
"docs/conf.py" = ["S102"]
# Boundary catch-alls. Every `except Exception` in these files converts
# an arbitrary failure into a reported result or a debug log instead of
# letting it escape a place that cannot report it:
# middleware.py the MCP error boundary, renders any tool exception
# as an `is_error` result
# batch_tools.py isolates one failed operation from its batch
# pane_tools/io.py same, per send_keys operation
# server.py best-effort buffer GC during lifespan shutdown
# server_tools.py best-effort tmux metadata probes over sockets that
# may be stale, denied, or running an old tmux
# mcp_swap.py restores a CLI config's original bytes when a
# write fails
# tmux and the CLI configs fail in more ways than a narrowed clause can
# enumerate, so narrowing here trades a logged degradation for a crash.
"scripts/mcp_swap.py" = ["BLE001"]
"src/libtmux_mcp/middleware.py" = ["BLE001"]
"src/libtmux_mcp/server.py" = ["BLE001"]
"src/libtmux_mcp/tools/batch_tools.py" = ["BLE001"]
"src/libtmux_mcp/tools/pane_tools/io.py" = ["BLE001"]
"src/libtmux_mcp/tools/server_tools.py" = ["BLE001"]

[tool.pytest.ini_options]
addopts = [
Expand Down
8 changes: 4 additions & 4 deletions scripts/mcp_swap.py
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["tomlkit>=0.13"]
Expand Down Expand Up @@ -648,7 +648,7 @@ def resolve_repo_meta(repo: pathlib.Path) -> tuple[str, str]:
msg = f"{pyproject} has no [project] table"
raise RuntimeError(msg)
name = str(project["name"])
server = name[: -len("-mcp")] if name.endswith("-mcp") else name
server = name.removesuffix("-mcp")
scripts = project.get("scripts") or {}
if not scripts:
msg = f"{pyproject} has no [project.scripts] — cannot derive entry"
Expand Down Expand Up @@ -1187,7 +1187,7 @@ def _naming_hint(repo: pathlib.Path, server: str) -> str | None:
names.add(name)
if server_points or not names:
return None
pick = sorted(names)[0]
pick = min(names)
return (
f"note: nothing is registered under server {server!r}, but this repo is "
f"registered as {sorted(names)} — pass --server {pick} to target it"
Expand Down Expand Up @@ -1237,7 +1237,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
print(" (no CLI currently points at this repo)")

if all_repo_names and server not in all_repo_names:
pick = sorted(all_repo_names)[0]
pick = min(all_repo_names)
print(
f" ! server name mismatch: this repo is registered as "
f"{sorted(all_repo_names)}, not {server!r} — use --server {pick}"
Expand Down
18 changes: 8 additions & 10 deletions src/libtmux_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,14 @@
"clipboard history."
)

_BASE_INSTRUCTIONS = "\n\n".join(
(
_INSTR_HIERARCHY,
_INSTR_SCOPE,
_INSTR_METADATA_VS_CONTENT,
_INSTR_READ_TOOLS,
_INSTR_WAIT_NOT_POLL,
_INSTR_HOOKS_GAP,
_INSTR_BUFFERS_GAP,
)
_BASE_INSTRUCTIONS = (
f"{_INSTR_HIERARCHY}\n\n"
f"{_INSTR_SCOPE}\n\n"
f"{_INSTR_METADATA_VS_CONTENT}\n\n"
f"{_INSTR_READ_TOOLS}\n\n"
f"{_INSTR_WAIT_NOT_POLL}\n\n"
f"{_INSTR_HOOKS_GAP}\n\n"
f"{_INSTR_BUFFERS_GAP}"
)

_INSTRUCTIONS_MAX_BYTES = 2048
Expand Down
30 changes: 20 additions & 10 deletions tests/test_pane_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -980,10 +980,14 @@ class FilterCurrentSyncLineFixture(t.NamedTuple):
"current_wrapped_long_marker",
[
"RUN_OK",
"∙ }; __libtmux_mcp_status=$?; tmux set-option -p "
f"@libtmux_mcp_status_{_CURRENT_ID[:10]}",
f'{_CURRENT_ID[10:]} "$__libtmux_mcp_status"; '
"tmux wait-for -S libtmux_mcp_run_",
(
"∙ }; __libtmux_mcp_status=$?; tmux set-option -p "
f"@libtmux_mcp_status_{_CURRENT_ID[:10]}"
),
(
f'{_CURRENT_ID[10:]} "$__libtmux_mcp_status"; '
"tmux wait-for -S libtmux_mcp_run_"
),
_CURRENT_ID,
],
f"libtmux_mcp_run_{_CURRENT_ID}",
Expand All @@ -993,10 +997,14 @@ class FilterCurrentSyncLineFixture(t.NamedTuple):
"previous_wrapped_long_marker",
[
"RUN_OK",
"∙ }; __libtmux_mcp_status=$?; tmux set-option -p "
f"@libtmux_mcp_status_{_PREVIOUS_ID[:10]}",
f'{_PREVIOUS_ID[10:]} "$__libtmux_mcp_status"; '
"tmux wait-for -S libtmux_mcp_run_",
(
"∙ }; __libtmux_mcp_status=$?; tmux set-option -p "
f"@libtmux_mcp_status_{_PREVIOUS_ID[:10]}"
),
(
f'{_PREVIOUS_ID[10:]} "$__libtmux_mcp_status"; '
"tmux wait-for -S libtmux_mcp_run_"
),
_PREVIOUS_ID,
],
f"libtmux_mcp_run_{_CURRENT_ID}",
Expand All @@ -1006,8 +1014,10 @@ class FilterCurrentSyncLineFixture(t.NamedTuple):
"current_short_marker",
[
"RUN_OK",
f'∙ }}; s=$?; tmux set-option -p @s_{_SHORT_CURRENT_ID} "$s"; '
f"tmux wait-for -S r_{_SHORT_CURRENT_ID}",
(
f'∙ }}; s=$?; tmux set-option -p @s_{_SHORT_CURRENT_ID} "$s"; '
f"tmux wait-for -S r_{_SHORT_CURRENT_ID}"
),
],
f"r_{_SHORT_CURRENT_ID}",
f"@s_{_SHORT_CURRENT_ID}",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ async def _render() -> str:
block = re.search(
r'\*\*Sample render\*\* \(``pane_id="%1"``\):\n\n````markdown\n(.*?)````',
page.read_text(),
re.S,
re.DOTALL,
)
assert block is not None, "interrupt_gracefully sample-render block not found"

Expand Down
Loading
Loading