Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
6c3ce51
Ops(feat): Add typed operation and engine spine
tony Jun 21, 2026
e15a78a
Ops(feat): Add classic + concrete engines and contract suite
tony Jun 21, 2026
7fb5aed
Ops(feat): Add async engine, lazy plans, and op catalog
tony Jun 21, 2026
e121f37
Ops(feat): Add persistent control-mode engine
tony Jun 21, 2026
4c4c6da
Ops(feat): Add eager + lazy pane facades over the spine
tony Jun 21, 2026
9fb2935
Ops(feat): Add async control-mode + concrete engines
tony Jun 21, 2026
7377ef9
Ops(feat): Add AckResult for no-output operations
tony Jun 21, 2026
5874574
Models(feat): Add pure object-graph snapshots
tony Jun 21, 2026
644272f
Ops(feat): Add read-seam list operations
tony Jun 21, 2026
88dec1b
docs(experimental): Add tmuxop-catalog directive
tony Jun 21, 2026
7cd0901
ControlMode(fix): Consume startup ACK, drain unsolicited blocks
tony Jun 21, 2026
d542377
Ops(feat): Add lazy-plan chainability (>> and ; folding)
tony Jun 21, 2026
a83a76a
Facade(feat): Add Server/Session/Window facades + creation ops
tony Jun 21, 2026
6184f47
Imsg(feat): Add native imsg engine + live parity test
tony Jun 21, 2026
8653bf9
Facade(feat): Complete the facade matrix (Server/Session/Client)
tony Jun 21, 2026
52dbcf5
chore(deps[dev]): Add ty type checker + config
tony Jun 21, 2026
b3d503e
Ops(feat): Add pluggable planners + {marked} fold
tony Jun 21, 2026
4232e04
Ops(feat): Add non-list read operations
tony Jun 21, 2026
07ad713
Ops(feat): Add pane mutation/creation operations
tony Jun 21, 2026
5fad700
Ops(feat): Add window mutation/navigation operations
tony Jun 21, 2026
ab6739b
Ops(feat): Add server/option/environment operations
tony Jun 21, 2026
a14a7fd
Ops(feat): Add paste-buffer operations
tony Jun 21, 2026
32130af
docs(experimental): Document engines and lazy plans
tony Jun 21, 2026
c2f234f
docs(CHANGES): Note experimental operations and engines
tony Jun 21, 2026
e115eaf
Ops(fix): Correct move-window -k and paste-buffer -r
tony Jun 21, 2026
5b41cde
Ops(fix): Resolve SlotRef src_target in lazy plans
tony Jun 21, 2026
0ba08f5
Ops(fix): Skip all decorates when a marked-fold create fails
tony Jun 21, 2026
8c2b243
Ops(fix): Mark save-buffer readonly to match its effects
tony Jun 21, 2026
a9f05af
Ops(docs): Fix PipePane parameter name in docstring
tony Jun 21, 2026
e07af70
Ops(fix): Log imsg argv as a scalar tmux_cmd field
tony Jun 21, 2026
2e0b112
Ops(docs): Add doctests to the planner plan() methods
tony Jun 21, 2026
02c32dd
Ops(fix): Resolve decorate src_target in {marked} folds
tony Jun 21, 2026
91da042
Ops(fix): Keep ; a bare separator in control-mode engines
tony Jun 21, 2026
90a57a1
Ops(fix): Treat a blank captured id as no id in marked folds
tony Jun 21, 2026
380ea37
Ops(fix): Complete a marked fold whose creator does not capture
tony Jun 21, 2026
db55709
Ops(fix): Drop create stdout when attributing marked decorates
tony Jun 21, 2026
b73b564
Ops(fix): Target the concrete pane in marked decorate results
tony Jun 21, 2026
e14e913
Ops(fix): Decode SubprocessEngine output as UTF-8
tony Jun 21, 2026
851c451
Models(refactor): Use namespaced dataclasses.replace in snapshots
tony Jun 21, 2026
4c375ca
Ops(docs): Fix PipePane -o flag description
tony Jun 21, 2026
f86e025
Ops(fix): Mark save-buffer mutating (it writes a file)
tony Jun 21, 2026
28cff01
Ops(fix): Correlate control-mode blocks per command and by flags
tony Jun 21, 2026
551fa84
Ops(fix): Clear pending futures on async control-mode write failure
tony Jun 21, 2026
deceb8b
Ops(fix): Suppress ProcessLookupError on async cancel terminate
tony Jun 21, 2026
7f18520
Engines(fix): Remove the unreachable asyncio engine kind
tony Jun 21, 2026
f5bead1
Ops(fix): Normalize tmux master version in operation gates
tony Jun 21, 2026
a30944b
Ops(fix): Reject SendKeys literal+enter combination
tony Jun 21, 2026
585f6c1
Ops(fix): Keep all lines of a display-message result
tony Jun 21, 2026
87e279e
Ops(fix): Centralize the has-session stderr->stdout fold in the op
tony Jun 21, 2026
926faf5
Engines(docs): Note ConcreteEngine query-simulation limits
tony Jun 22, 2026
6e01b53
Engines(fix): Avoid imsg UnboundLocalError on socket() failure
tony Jun 22, 2026
5c5b4d3
Engines(fix): Return imsg exit result on clean close after MSG_EXIT
tony Jun 22, 2026
86bbf6c
Engines(fix): Close imsg dup'd fds if the identify send never happens
tony Jun 22, 2026
fe12ab6
Imsg(fix): Send identify LONGFLAGS frame once
tony Jun 22, 2026
06c66d4
Engines(test): Widen async control-mode coverage
tony Jun 22, 2026
225a2df
Ops(feat[send_keys]): Add suppress_history flag
tony Jun 22, 2026
6c0a093
Ops(feat): Capture implicit child ids on create
tony Jun 22, 2026
9ba2530
Workspace(feat): Declarative WorkspaceBuilder on the typed-ops Core
tony Jun 22, 2026
1a2de88
Ops(feat): Serialize bindings + add plan preview
tony Jun 22, 2026
e255d7d
Mcp(feat): Add framework-agnostic tool projection
tony Jun 23, 2026
c4ea4e5
Mcp(feat): Add optional fastmcp adapter (libtmux[mcp])
tony Jun 23, 2026
ffc2ebc
Mcp(feat): Per-op + plan tools and a stdio server
tony Jun 23, 2026
5637329
Mcp(feat): Port mcp_swap config-swap dev script
tony Jun 23, 2026
2904003
Tests(chore): Run the mcp adapter suite in the gate
tony Jun 23, 2026
79bc89b
Workspace(test): Cover analyzer, compiler, runner
tony Jun 23, 2026
e86b1af
Mcp(feat): Add grok + agy CLIs to mcp_swap
tony Jun 23, 2026
9865eb1
Mcp(feat): Caller-aware async tmux tool surface
tony Jun 23, 2026
b6a3c65
Mcp(feat): Caller discovery + self-kill guards
tony Jun 23, 2026
00d686a
Mcp(fix): Harden self-kill guards + socket scoping
tony Jun 23, 2026
c978473
Mcp(feat): Needle-free pane-output monitor
tony Jun 23, 2026
8ec03a9
Mcp(feat): Make wait_for_output discoverable
tony Jun 24, 2026
ade42f9
Mcp(fix): Close self-kill guard deferrals
tony Jun 24, 2026
4229265
Mcp(fix): Harden wait_for_output monitor
tony Jun 24, 2026
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
28 changes: 28 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### What's new

#### Experimental operations and engines (#690)

Operations describe tmux commands as data. Each renders its argv against a tmux
version (dropping flags an older tmux cannot accept), adapts raw output into a
typed result, and serializes to and from plain dicts -- all without a running
tmux server. The set spans the read seam (``list-*``, ``has-session``,
``display-message``, ``show-options``, ``show-buffer``) and the
mutating/creating surface for panes, windows, the server, options, environment,
hooks, and paste buffers. A registry-generated catalog on the {ref}`experimental`
page always matches the code.

Engines run those operations behind one protocol, so the same operation returns
the same typed result whether it goes through a subprocess (the classic path
that reproduces today's libtmux behavior), an in-memory simulator for tests and
dry runs, a persistent ``tmux -C`` control connection, an async transport, or
tmux's native binary peer protocol. Results never raise on construction;
raising is opt-in via ``raise_for_status()``, and how a failed result is handled
is each engine's policy.

A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations and yields
forward references so a later operation can target an object that does not exist
yet, resolved against captured ids at execution time. How a plan becomes tmux
dispatches is a pluggable {class}`~libtmux.experimental.ops.planner.Planner`
(sequential, ``;``-folding, or ``{marked}``-folding), so dispatch strategies can
be A/B tested against the same plan with identical results.

## libtmux 0.58.1 (2026-06-16)

libtmux 0.58.1 restores compatibility with pytest 9.1. The bundled
Expand Down
120 changes: 120 additions & 0 deletions docs/_ext/tmuxop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Sphinx directive that renders the experimental operation catalog.

``.. tmuxop-catalog::`` (or the MyST fenced form) walks
:func:`libtmux.experimental.ops.catalog` and emits a table of operations with
their scope, safety tier, result type, minimum tmux version, and summary. The
operation registry is the single source of truth, so the rendered reference
cannot drift from the code.

Options
-------
``:scope:`` / ``:safety:``
Filter to one scope (``pane``/``window``/``session``/``server``/``client``)
or safety tier (``readonly``/``mutating``/``destructive``).
``:primitive-only:``
Show only operations that wrap a single tmux command.

This is the in-repo renderer; a full gp-sphinx ``tmuxop`` domain (cross-reference
roles + an operations index) can later replace it under the same directive name.
"""

from __future__ import annotations

import typing as t

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective

if t.TYPE_CHECKING:
from collections.abc import Sequence

from sphinx.application import Sphinx

logger = logging.getLogger(__name__)

_HEADERS = ("Operation", "Command", "Scope", "Safety", "Result", "Min tmux", "Summary")


def _row(cells: Sequence[str]) -> nodes.row:
"""Build a docutils table row from string cells."""
row = nodes.row()
for cell in cells:
entry = nodes.entry()
entry += nodes.paragraph(text=cell)
row += entry
return row


def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> nodes.table:
"""Build a simple docutils table."""
table = nodes.table()
tgroup = nodes.tgroup(cols=len(headers))
table += tgroup
for _ in headers:
tgroup += nodes.colspec(colwidth=1)
thead = nodes.thead()
thead += _row(headers)
tgroup += thead
tbody = nodes.tbody()
for row in rows:
tbody += _row(row)
tgroup += tbody
return table


class TmuxopCatalogDirective(SphinxDirective):
"""Render the operation catalog as a table."""

has_content = False
option_spec: t.ClassVar[dict[str, t.Any]] = {
"scope": directives.unchanged,
"safety": directives.unchanged,
"primitive-only": directives.flag,
}

def run(self) -> list[nodes.Node]:
"""Build the catalog table from the operation registry."""
from libtmux.experimental.ops import catalog

entries = catalog()
scope = self.options.get("scope")
safety = self.options.get("safety")
if scope:
entries = [entry for entry in entries if entry.scope == scope]
if safety:
entries = [entry for entry in entries if entry.safety == safety]
if "primitive-only" in self.options:
entries = [entry for entry in entries if entry.primitive]

if not entries:
logger.warning(
"tmuxop-catalog: no operations matched the given filters",
location=self.get_location(),
)
return []

rows = [
(
entry.kind,
entry.command,
entry.scope,
entry.safety,
entry.result_type,
entry.min_version or "-",
entry.summary,
)
for entry in entries
]
return [_table(_HEADERS, rows)]


def setup(app: Sphinx) -> dict[str, t.Any]:
"""Register the directive."""
app.add_directive("tmuxop-catalog", TmuxopCatalogDirective)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
project_src = project_root / "src"

sys.path.insert(0, str(project_src))
sys.path.insert(0, str(cwd / "_ext"))

# package data
about: dict[str, str] = {}
Expand All @@ -34,6 +35,7 @@
"sphinx_autodoc_api_style",
"sphinx_autodoc_pytest_fixtures",
"sphinx.ext.todo",
"tmuxop",
],
intersphinx_mapping={
"python": ("https://docs.python.org/", None),
Expand Down
136 changes: 136 additions & 0 deletions docs/experimental.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
(experimental)=

# Experimental: operations & engines

```{warning}
Everything under {mod}`libtmux.experimental` is **not** covered by the
versioning policy and may change or be removed between any two releases.
```

`libtmux.experimental` hosts an inert, typed *operation* substrate and the
*engines* that execute it. An operation describes a tmux command (it renders
argv, carries its result type and metadata, and serializes) without dispatching;
an engine runs operations and returns typed results. The same operation returns
the same typed result whether executed by a subprocess, an in-memory simulator,
a persistent `tmux -C` control connection, or an async transport.

See ``tmux-python/libtmux`` issue 689 for the operationalization plan.

## Running an operation

An operation is a value; ``run`` (or ``arun`` for async) hands it to an engine
and returns the engine's typed result. Results never raise on construction --
inspect ``ok``/``status``, or opt into raising with ``raise_for_status()``:

```python
>>> from libtmux.experimental.ops import HasSession, run
>>> from libtmux.experimental.ops._types import SessionId
>>> from libtmux.experimental.engines import ConcreteEngine
>>> result = run(HasSession(target=SessionId("$0")), ConcreteEngine())
>>> result.ok
True
>>> result.raise_for_status() is result
True
```

How a *failed* result is treated is the engine's policy: the classic subprocess
path raises in its facade to match today's libtmux behavior, while the newer
engines hand the result back and let the caller decide.

## Choosing an engine

Every engine satisfies the same ``TmuxEngine`` (or ``AsyncTmuxEngine``)
protocol, so swapping engines never changes an operation or its result type --
only *how* and *where* the command runs.

| Engine | Transport | Use it for |
| --- | --- | --- |
| ``SubprocessEngine`` | one ``tmux`` process per command | the classic path; reproduces today's libtmux behavior |
| ``ConcreteEngine`` | in-memory, no tmux | tests and dry runs (deterministic, fabricated output) |
| ``ControlModeEngine`` | a persistent ``tmux -C`` connection | many commands over one long-lived session |
| ``ImsgEngine`` | tmux's native binary peer protocol | an opt-in easter egg |

Each has an ``Async*`` counterpart (``AsyncSubprocessEngine``,
``AsyncConcreteEngine``, ``AsyncControlModeEngine``) behind ``AsyncTmuxEngine``.
Construct one directly, bind it to a live server with
``SubprocessEngine.for_server(server)``, or select one by name from the engine
registry:

```python
>>> from libtmux.experimental.engines import available_engines, create_engine
>>> from libtmux.experimental.ops import HasSession, run
>>> from libtmux.experimental.ops._types import SessionId
>>> available_engines()
('concrete', 'control_mode', 'imsg', 'subprocess')
>>> engine = create_engine("concrete")
>>> run(HasSession(target=SessionId("$0")), engine).status
'complete'
```

## Lazy plans and planners

A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations without
running them, returning a forward *slot reference* for each created object so a
later operation can target something that does not exist yet. ``execute``
(or ``aexecute``) resolves those references against captured ids as it goes:

```python
>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys
>>> from libtmux.experimental.ops._types import WindowId
>>> from libtmux.experimental.engines import ConcreteEngine
>>> plan = LazyPlan()
>>> pane = plan.add(SplitWindow(target=WindowId("@1")))
>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True))
>>> outcome = plan.execute(ConcreteEngine())
>>> outcome.ok
True
>>> [r.status for r in outcome.results]
['complete', 'complete']
```

Operations also compose with ``>>`` into a chain, which a plan can run as one
dispatch when the members are chainable.

*How* a plan turns into dispatches is a pluggable
{class}`~libtmux.experimental.ops.planner.Planner`, so strategies can be A/B
tested against the same plan:

- ``SequentialPlanner`` -- one dispatch per operation (the default).
- ``FoldingPlanner`` -- folds adjacent chainable operations into a single
``;``-separated dispatch.
- ``MarkedPlanner`` -- folds a "create then decorate the new pane" run into one
dispatch using tmux's ``{marked}`` register.

Every planner produces the same per-operation result; they differ only in how
many times tmux is invoked:

```python
>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys, FoldingPlanner
>>> from libtmux.experimental.ops._types import WindowId
>>> from libtmux.experimental.engines import ConcreteEngine
>>> plan = LazyPlan()
>>> pane = plan.add(SplitWindow(target=WindowId("@1")))
>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True))
>>> plan.execute(ConcreteEngine(), planner=FoldingPlanner()).ok
True
```

## Operation catalog

The catalog below is generated from the operation registry, so it always matches
the code.

```{tmuxop-catalog}
```

### Read-only operations

```{tmuxop-catalog}
:safety: readonly
```

### Destructive operations

```{tmuxop-catalog}
:safety: destructive
```
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ api/index
api/testing/index
internals/index
project/index
experimental
history
migration
glossary
Expand Down
9 changes: 7 additions & 2 deletions docs/topics/automation_patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,18 @@ True

### Waiting for specific output

> **Note:** This polls with `capture_pane` + `sleep` — correct for the
> synchronous library. If you drive tmux through the libtmux MCP server, prefer
> the event-backed `wait_for_output` tool instead: it folds live `%output` and
> returns when the pane settles, with no polling.

```python
>>> import time

>>> monitor_window = session.new_window(window_name='monitor', attach=False)
>>> monitor_pane = monitor_window.active_pane

>>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1):
>>> def wait_for_text(pane, text, timeout=5.0, poll_interval=0.1):
... """Wait for specific text to appear in pane output."""
... start = time.time()
... while time.time() - start < timeout:
Expand All @@ -93,7 +98,7 @@ True
... return False

>>> monitor_pane.send_keys('sleep 0.2; echo "READY"')
>>> wait_for_output(monitor_pane, 'READY', timeout=2.0)
>>> wait_for_text(monitor_pane, 'READY', timeout=2.0)
True

>>> # Clean up
Expand Down
11 changes: 11 additions & 0 deletions fastmcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
"source": {
"type": "filesystem",
"path": "src/libtmux/experimental/mcp/__init__.py",
"entrypoint": "default_async_server"
},
"deployment": {
"transport": "stdio"
}
}
Loading
Loading