From 7e1eee19aa7eba7abed140f651294cf4c2f5b008 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:02:03 -0500 Subject: [PATCH 001/223] Ops(feat): Add typed operation and engine spine why: Operationalizes the typed-operations/engines architecture (issues 688, 689) with the pure substrate that was absent from every prototype branch: an inert, statically-typed operation value that renders tmux commands, carries its result type, and serializes without a live tmux server. Engines stay transport-agnostic over it. None of this touches or changes existing public APIs. what: - Add libtmux.experimental.{ops,engines} packages (experimental, not under the versioning policy) - ops: frozen Operation[ResultT] with class-level metadata as the single source of truth; pure render() with declarative version gating (LooseVersion); build_result() adapting raw output to typed results - ops: typed Result base + raise_for_status() (CPython/requests precedent), SplitWindowResult/CapturePaneResult payloads - ops: closed Target sum (PaneId/WindowId/SessionId/ClientName/NameRef/ IndexRef/Special/SlotRef) with fail-closed validation - ops: fail-closed OperationRegistry keyed by kind, with OpSpec views and predicate listing; stdlib dict serialization with round-trips - ops: four seed operations (split-window, capture-pane, send-keys, select-layout) registered via @register - engines: TmuxEngine/AsyncTmuxEngine protocols, CommandRequest/ CommandResult, EngineSpec; run()/arun() execute bridge sharing one render/build path (sync vs await is the only divergence) - tests: 111 pure, fixture-parametrizable unit tests + doctests, all runnable without a tmux server --- src/libtmux/experimental/__init__.py | 19 + src/libtmux/experimental/engines/__init__.py | 31 ++ src/libtmux/experimental/engines/base.py | 179 ++++++++++ src/libtmux/experimental/ops/__init__.py | 115 ++++++ src/libtmux/experimental/ops/_ops/__init__.py | 20 ++ .../experimental/ops/_ops/capture_pane.py | 110 ++++++ .../experimental/ops/_ops/select_layout.py | 44 +++ .../experimental/ops/_ops/send_keys.py | 55 +++ .../experimental/ops/_ops/split_window.py | 107 ++++++ src/libtmux/experimental/ops/_types.py | 326 ++++++++++++++++++ src/libtmux/experimental/ops/exc.py | 121 +++++++ src/libtmux/experimental/ops/execute.py | 115 ++++++ src/libtmux/experimental/ops/operation.py | 254 ++++++++++++++ src/libtmux/experimental/ops/registry.py | 207 +++++++++++ src/libtmux/experimental/ops/results.py | 170 +++++++++ src/libtmux/experimental/ops/serialize.py | 181 ++++++++++ tests/experimental/__init__.py | 3 + tests/experimental/ops/__init__.py | 3 + tests/experimental/ops/test_execute.py | 107 ++++++ tests/experimental/ops/test_operation.py | 105 ++++++ tests/experimental/ops/test_registry.py | 74 ++++ tests/experimental/ops/test_results.py | 73 ++++ tests/experimental/ops/test_serialize.py | 106 ++++++ tests/experimental/ops/test_types.py | 82 +++++ 24 files changed, 2607 insertions(+) create mode 100644 src/libtmux/experimental/__init__.py create mode 100644 src/libtmux/experimental/engines/__init__.py create mode 100644 src/libtmux/experimental/engines/base.py create mode 100644 src/libtmux/experimental/ops/__init__.py create mode 100644 src/libtmux/experimental/ops/_ops/__init__.py create mode 100644 src/libtmux/experimental/ops/_ops/capture_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/select_layout.py create mode 100644 src/libtmux/experimental/ops/_ops/send_keys.py create mode 100644 src/libtmux/experimental/ops/_ops/split_window.py create mode 100644 src/libtmux/experimental/ops/_types.py create mode 100644 src/libtmux/experimental/ops/exc.py create mode 100644 src/libtmux/experimental/ops/execute.py create mode 100644 src/libtmux/experimental/ops/operation.py create mode 100644 src/libtmux/experimental/ops/registry.py create mode 100644 src/libtmux/experimental/ops/results.py create mode 100644 src/libtmux/experimental/ops/serialize.py create mode 100644 tests/experimental/__init__.py create mode 100644 tests/experimental/ops/__init__.py create mode 100644 tests/experimental/ops/test_execute.py create mode 100644 tests/experimental/ops/test_operation.py create mode 100644 tests/experimental/ops/test_registry.py create mode 100644 tests/experimental/ops/test_results.py create mode 100644 tests/experimental/ops/test_serialize.py create mode 100644 tests/experimental/ops/test_types.py diff --git a/src/libtmux/experimental/__init__.py b/src/libtmux/experimental/__init__.py new file mode 100644 index 000000000..1bcc80a96 --- /dev/null +++ b/src/libtmux/experimental/__init__.py @@ -0,0 +1,19 @@ +"""Experimental libtmux APIs. + +This package hosts work that is **not** covered by the project's versioning +policy. Anything under :mod:`libtmux.experimental` may change shape or be +removed between any two releases without notice. + +Current contents: + +- :mod:`libtmux.experimental.ops` -- inert, typed tmux *operation* values: the + pure source of truth that renders tmux commands, carries result types, and + serializes without a live tmux server. +- :mod:`libtmux.experimental.engines` -- *engine* protocols and + implementations that execute operations and return typed results. + +See the operationalization plan (``tmux-python/libtmux`` issue 689) and the +architecture proposal (issue 688) for background. +""" + +from __future__ import annotations diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py new file mode 100644 index 000000000..fa9961319 --- /dev/null +++ b/src/libtmux/experimental/engines/__init__.py @@ -0,0 +1,31 @@ +"""Execution engines for :mod:`libtmux.experimental.ops`. + +An *engine* executes a rendered tmux command and returns a structured result. +Engines are interchangeable behind the :class:`~.base.TmuxEngine` / +:class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run +through a subprocess, a persistent ``tmux -C`` control connection, an async +transport, an in-memory simulator, or (as an easter egg) tmux's native binary +peer protocol -- and return the *same* typed result. + +See the operationalization plan (``tmux-python/libtmux`` issue 689). +""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import ( + AsyncTmuxEngine, + CommandRequest, + CommandResult, + EngineKind, + EngineSpec, + TmuxEngine, +) + +__all__ = ( + "AsyncTmuxEngine", + "CommandRequest", + "CommandResult", + "EngineKind", + "EngineSpec", + "TmuxEngine", +) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py new file mode 100644 index 000000000..9195dbb61 --- /dev/null +++ b/src/libtmux/experimental/engines/base.py @@ -0,0 +1,179 @@ +"""Core engine abstractions: requests, results, and the engine protocols. + +Adapted from the ``libtmux-protocol-engines`` prototype. A +:class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a +:class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and +:class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with +the right methods is an engine -- including a live :class:`libtmux.Server` for +the classic case -- without inheriting a base class. +""" + +from __future__ import annotations + +import enum +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + +@dataclass(frozen=True) +class CommandRequest: + """A rendered tmux command, ready for an engine to execute. + + Parameters + ---------- + args : tuple[str, ...] + The tmux argv *after* the binary (e.g. ``("split-window", "-t", "%1")``). + tmux_bin : str or None + Override the tmux binary for this request; ``None`` lets the engine + decide. + + Examples + -------- + >>> CommandRequest.from_args("split-window", "-t", "%1") + CommandRequest(args=('split-window', '-t', '%1'), tmux_bin=None) + >>> CommandRequest.from_args("kill-window", "-t", 2).args + ('kill-window', '-t', '2') + """ + + args: tuple[str, ...] + tmux_bin: str | None = None + + @classmethod + def from_args( + cls, + *args: t.Any, + tmux_bin: str | pathlib.Path | None = None, + ) -> CommandRequest: + """Build a request from arbitrary tokens, stringifying each.""" + return cls( + args=tuple(map(str, args)), + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + ) + + +@dataclass(frozen=True) +class CommandResult: + """The structured outcome of executing a :class:`CommandRequest`. + + A tmux-side failure (``%error`` / nonzero exit) is *data* here -- it sets + ``returncode`` and ``stderr`` rather than raising. Only engine-broken + conditions (missing binary, lost connection, protocol desync) raise. + + Parameters + ---------- + cmd : tuple[str, ...] + The full argv that ran (including the tmux binary). + stdout, stderr : tuple[str, ...] + Captured output lines. + returncode : int + tmux exit code (``-1`` when unknown). + """ + + cmd: tuple[str, ...] + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + returncode: int = 0 + + +class EngineKind(str, enum.Enum): + """Named engine families.""" + + SUBPROCESS = "subprocess" + CONCRETE = "concrete" + CONTROL_MODE = "control_mode" + ASYNCIO = "asyncio" + IMSG = "imsg" + + +@dataclass(frozen=True) +class EngineSpec: + """A typed, serializable selector for an engine family. + + Examples + -------- + >>> EngineSpec.subprocess().kind + + >>> EngineSpec.imsg(protocol_version=8).protocol_version + 8 + >>> EngineSpec.subprocess(protocol_version=8) + Traceback (most recent call last): + ... + ValueError: protocol_version is only valid for the imsg engine + """ + + kind: EngineKind + protocol_version: int | None = None + extra: t.Mapping[str, t.Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Normalize and validate the spec.""" + kind = EngineKind(self.kind) + if kind is not EngineKind.IMSG and self.protocol_version is not None: + msg = "protocol_version is only valid for the imsg engine" + raise ValueError(msg) + object.__setattr__(self, "kind", kind) + + @classmethod + def subprocess(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build a subprocess (classic) engine spec.""" + return cls(kind=EngineKind.SUBPROCESS, protocol_version=protocol_version) + + @classmethod + def concrete(cls) -> EngineSpec: + """Build a concrete (in-memory) engine spec.""" + return cls(kind=EngineKind.CONCRETE) + + @classmethod + def control_mode(cls) -> EngineSpec: + """Build a control-mode engine spec.""" + return cls(kind=EngineKind.CONTROL_MODE) + + @classmethod + def asyncio(cls) -> EngineSpec: + """Build an asyncio engine spec.""" + return cls(kind=EngineKind.ASYNCIO) + + @classmethod + def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build an imsg (native binary) engine spec.""" + return cls(kind=EngineKind.IMSG, protocol_version=protocol_version) + + +@t.runtime_checkable +class TmuxEngine(t.Protocol): + """A synchronous executor of tmux commands.""" + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request. + + Persistent-connection engines (control mode) override this to pipeline; + stateless engines implement it as a loop over :meth:`run`. + """ + ... + + +@t.runtime_checkable +class AsyncTmuxEngine(t.Protocol): + """An asynchronous executor of tmux commands.""" + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request.""" + ... diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py new file mode 100644 index 000000000..c39f871d5 --- /dev/null +++ b/src/libtmux/experimental/ops/__init__.py @@ -0,0 +1,115 @@ +"""Inert, typed tmux operation values. + +This package is the pure source of truth for tmux commands: each +:class:`~.operation.Operation` renders a tmux argv, carries its result type and +metadata, and adapts raw output into a typed :class:`~.results.Result` -- all +without a live tmux server. Engines (:mod:`libtmux.experimental.engines`) +execute operations; :func:`run` / :func:`arun` bridge the two. + +Everything here is experimental and not covered by the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.ops import SplitWindow, run +>>> from libtmux.experimental.ops._types import PaneId +>>> from libtmux.experimental.engines import CommandResult +>>> SplitWindow(target=PaneId("%1"), horizontal=True).render() +('split-window', '-t', '%1', '-h', '-P', '-F', '#{pane_id}') +""" + +from __future__ import annotations + +from libtmux.experimental.ops._ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import ( + ClientName, + Effects, + IndexRef, + NameRef, + PaneId, + Safety, + Scope, + SessionId, + SlotRef, + Special, + Status, + Target, + WindowId, + render_target, +) +from libtmux.experimental.ops.exc import ( + DuplicateOperation, + OperationError, + TmuxCommandError, + UnknownOperation, + VersionUnsupported, +) +from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import ( + OperationRegistry, + OpSpec, + register, + registry, +) +from libtmux.experimental.ops.results import ( + CapturePaneResult, + Result, + SplitWindowResult, + status_for, +) +from libtmux.experimental.ops.serialize import ( + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + target_from_dict, + target_to_dict, +) + +__all__ = ( + "CapturePane", + "CapturePaneResult", + "ClientName", + "DuplicateOperation", + "Effects", + "IndexRef", + "NameRef", + "OpSpec", + "Operation", + "OperationError", + "OperationRegistry", + "PaneId", + "Result", + "Safety", + "Scope", + "SelectLayout", + "SendKeys", + "SessionId", + "SlotRef", + "Special", + "SplitWindow", + "SplitWindowResult", + "Status", + "Target", + "TmuxCommandError", + "UnknownOperation", + "VersionUnsupported", + "WindowId", + "arun", + "operation_from_dict", + "operation_to_dict", + "register", + "registry", + "render_target", + "result_from_dict", + "result_to_dict", + "run", + "status_for", + "target_from_dict", + "target_to_dict", +) diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py new file mode 100644 index 000000000..8c050ec44 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -0,0 +1,20 @@ +"""Concrete seed operations. + +Importing this package registers each operation in the default registry +(:data:`libtmux.experimental.ops.registry.registry`) as a side effect of the +``@register`` decorator on each class. +""" + +from __future__ import annotations + +from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.select_layout import SelectLayout +from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.split_window import SplitWindow + +__all__ = ( + "CapturePane", + "SelectLayout", + "SendKeys", + "SplitWindow", +) diff --git a/src/libtmux/experimental/ops/_ops/capture_pane.py b/src/libtmux/experimental/ops/_ops/capture_pane.py new file mode 100644 index 000000000..598e0a3da --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/capture_pane.py @@ -0,0 +1,110 @@ +"""The ``capture-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CapturePaneResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class CapturePane(Operation[CapturePaneResult]): + """Capture a pane's contents (a read-only operation). + + Parameters + ---------- + start, end : int or None + Start/end line for the capture (``-S`` / ``-E``). + escape_sequences : bool + Include escape sequences (``-e``). + join_wrapped : bool + Join wrapped lines (``-J``). + trim_trailing : bool + Trim trailing whitespace (``-T``; tmux 3.4+, dropped on older tmux). + mode_screen : bool + Capture the visible screen in copy mode (``-M``; tmux 3.6+). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> CapturePane(target=PaneId("%1")).render() + ('capture-pane', '-t', '%1', '-p') + + Version-gated flags are dropped on older tmux: + + >>> op = CapturePane(target=PaneId("%1"), trim_trailing=True) + >>> op.render(version="3.3") + ('capture-pane', '-t', '%1', '-p') + >>> op.render(version="3.4") + ('capture-pane', '-t', '%1', '-p', '-T') + + Captured stdout is exposed as typed lines: + + >>> result = op.build_result(returncode=0, stdout=("foo", "bar")) + >>> result.lines + ('foo', 'bar') + """ + + kind = "capture_pane" + command = "capture-pane" + scope = "pane" + result_cls = CapturePaneResult + safety = "readonly" + effects = Effects(read_only=True, reads_output=True, idempotent=True) + flag_version_map: t.ClassVar[Mapping[str, str]] = { + "trim_trailing": "3.4", + "mode_screen": "3.6", + } + + start: int | None = None + end: int | None = None + escape_sequences: bool = False + join_wrapped: bool = False + trim_trailing: bool = False + mode_screen: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``capture-pane`` flags (``-p`` prints to stdout).""" + out: list[str] = ["-p"] + if self.start is not None: + out.extend(("-S", str(self.start))) + if self.end is not None: + out.extend(("-E", str(self.end))) + if self.escape_sequences: + out.append("-e") + if self.join_wrapped: + out.append("-J") + if self.trim_trailing and self.flag_available("trim_trailing", version): + out.append("-T") + if self.mode_screen and self.flag_available("mode_screen", version): + out.append("-M") + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + ) -> CapturePaneResult: + """Expose captured stdout as typed :attr:`~.CapturePaneResult.lines`.""" + return CapturePaneResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + lines=stdout, + ) diff --git a/src/libtmux/experimental/ops/_ops/select_layout.py b/src/libtmux/experimental/ops/_ops/select_layout.py new file mode 100644 index 000000000..f17af37b4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_layout.py @@ -0,0 +1,44 @@ +"""The ``select-layout`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import Result + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectLayout(Operation[Result]): + """Apply a layout to a window. + + Parameters + ---------- + layout : str or None + A named layout (``even-horizontal``, ``main-vertical``, ...) or a custom + layout string. ``None`` re-applies the current layout. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SelectLayout(target=WindowId("@1"), layout="main-vertical").render() + ('select-layout', '-t', '@1', 'main-vertical') + >>> SelectLayout(target=WindowId("@1")).render() + ('select-layout', '-t', '@1') + """ + + kind = "select_layout" + command = "select-layout" + scope = "window" + result_cls = Result + safety = "mutating" + effects = Effects(idempotent=True, mutates_layout=True) + + layout: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional layout argument.""" + return (self.layout,) if self.layout is not None else () diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py new file mode 100644 index 000000000..81de969fa --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -0,0 +1,55 @@ +"""The ``send-keys`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import Result + + +@register +@dataclass(frozen=True, kw_only=True) +class SendKeys(Operation[Result]): + """Send keys (input) to a pane. + + Parameters + ---------- + keys : str + The key string to send. + enter : bool + Append a literal ``Enter`` key after the input. + literal : bool + Send keys literally without tmux key-name lookup (``-l``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SendKeys(target=PaneId("%1"), keys="echo hi", enter=True).render() + ('send-keys', '-t', '%1', 'echo hi', 'Enter') + >>> SendKeys(target=PaneId("%1"), keys="q", literal=True).render() + ('send-keys', '-t', '%1', '-l', 'q') + """ + + kind = "send_keys" + command = "send-keys" + scope = "pane" + result_cls = Result + safety = "mutating" + effects = Effects(writes_input=True) + + keys: str + enter: bool = False + literal: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``send-keys`` flags and the key string.""" + out: list[str] = [] + if self.literal: + out.append("-l") + out.append(self.keys) + if self.enter: + out.append("Enter") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/split_window.py b/src/libtmux/experimental/ops/_ops/split_window.py new file mode 100644 index 000000000..abaec4734 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/split_window.py @@ -0,0 +1,107 @@ +"""The ``split-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class SplitWindow(Operation[SplitWindowResult]): + """Split a pane, creating a new pane. + + By default the operation appends ``-P -F '#{pane_id}'`` so the new pane's id + is captured on stdout; :meth:`build_result` reads it into + :attr:`~.results.SplitWindowResult.new_pane_id`. + + Parameters + ---------- + horizontal : bool + Split left/right (``-h``) instead of top/bottom (``-v``). + start_directory : str or None + Working directory for the new pane (``-c``). + environment : Mapping[str, str] or None + Environment variables for the new pane (``-e``; tmux 3.0+). + shell : str or None + A shell command to run in the new pane instead of the default shell. + capture : bool + Append ``-P -F '#{pane_id}'`` to capture the new pane id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SplitWindow(target=PaneId("%1"), horizontal=True).render() + ('split-window', '-t', '%1', '-h', '-P', '-F', '#{pane_id}') + + The ``-e`` environment flag is dropped on tmux older than 3.0: + + >>> op = SplitWindow(target=PaneId("%1"), environment={"E": "1"}) + >>> op.render(version="2.9") + ('split-window', '-t', '%1', '-v', '-P', '-F', '#{pane_id}') + >>> op.render(version="3.3") + ('split-window', '-t', '%1', '-v', '-eE=1', '-P', '-F', '#{pane_id}') + + The created pane id is parsed into the typed result: + + >>> result = op.build_result(returncode=0, stdout=("%2",)) + >>> result.new_pane_id + '%2' + """ + + kind = "split_window" + command = "split-window" + scope = "window" + result_cls = SplitWindowResult + safety = "mutating" + effects = Effects(creates="pane") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + horizontal: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``split-window`` flags.""" + out: list[str] = ["-h" if self.horizontal else "-v"] + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.capture: + out.extend(("-P", "-F", "#{pane_id}")) + if self.shell is not None: + out.append(self.shell) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + ) -> SplitWindowResult: + """Parse the captured new-pane id into the typed result.""" + new_pane_id = stdout[0].strip() if status == "complete" and stdout else None + return SplitWindowResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_pane_id=new_pane_id, + ) diff --git a/src/libtmux/experimental/ops/_types.py b/src/libtmux/experimental/ops/_types.py new file mode 100644 index 000000000..6fc387ff9 --- /dev/null +++ b/src/libtmux/experimental/ops/_types.py @@ -0,0 +1,326 @@ +"""Typed primitives shared across :mod:`libtmux.experimental.ops`. + +These are the small, inert vocabulary types the operation substrate is built +from: the tmux object :data:`Scope`, the :data:`Safety` tier and execution +:data:`Status` enumerations, the :class:`Effects` descriptor, and the closed +:data:`Target` sum that types a ``-t`` target so an illegal target is a type +error rather than a runtime surprise. + +Everything here is pure: no tmux server is required to construct, render, or +compare any of these values. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +Scope: t.TypeAlias = t.Literal["server", "session", "window", "pane", "client"] +"""The tmux object scope an operation targets. + +``client`` is a view into a live attachment rather than part of the ownership +chain, but it still has operation scope because tmux exposes client-scoped +commands (``detach-client``, ``switch-client``). +""" + +Safety: t.TypeAlias = t.Literal["readonly", "mutating", "destructive"] +"""Coarse safety tier, mirroring the MCP tool-annotation vocabulary. + +``readonly`` reads state, ``mutating`` changes it reversibly, ``destructive`` +removes objects (``kill-session``, ``kill-window``). +""" + +Status: t.TypeAlias = t.Literal["complete", "failed", "skipped", "unknown"] +"""Execution status of a result. + +``complete`` + The command ran and tmux reported success. +``failed`` + The command ran and tmux reported an error. +``skipped`` + The operation was never dispatched (e.g. an earlier command in a chain + failed, or a lazy plan was inspected but not executed). +``unknown`` + The outcome could not be determined (e.g. a control-mode timeout). +""" + + +@dataclass(frozen=True) +class Effects: + """Declarative description of what an operation does to tmux state. + + Carrying effects as typed data (rather than a hand-maintained table in a + downstream consumer) lets MCP annotations and safety tiers derive directly + from the operation. + + Parameters + ---------- + read_only : bool + The operation does not change tmux state. + destructive : bool + The operation removes an object. + idempotent : bool + Re-running the operation has the same effect as running it once. + creates : Scope or None + The scope of the object the operation creates, if any (e.g. + ``split-window`` creates a ``pane``). + writes_input : bool + The operation sends input into a pane (e.g. ``send-keys``). + reads_output : bool + The operation captures output from a pane (e.g. ``capture-pane``). + mutates_layout : bool + The operation rearranges panes/windows (e.g. ``select-layout``). + + Examples + -------- + >>> Effects(read_only=True, idempotent=True) + Effects(read_only=True, destructive=False, idempotent=True, creates=None, + writes_input=False, reads_output=False, mutates_layout=False) + >>> Effects(creates="pane").creates + 'pane' + """ + + read_only: bool = False + destructive: bool = False + idempotent: bool = False + creates: Scope | None = None + writes_input: bool = False + reads_output: bool = False + mutates_layout: bool = False + + +@dataclass(frozen=True, slots=True) +class PaneId: + """A concrete tmux pane id, ``%N``. + + Examples + -------- + >>> PaneId("%1").render() + '%1' + >>> PaneId("1") + Traceback (most recent call last): + ... + ValueError: PaneId must start with '%', got '1' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("%"): + msg = f"PaneId must start with '%', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class WindowId: + """A concrete tmux window id, ``@N``. + + Examples + -------- + >>> WindowId("@2").render() + '@2' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("@"): + msg = f"WindowId must start with '@', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class SessionId: + """A concrete tmux session id, ``$N``. + + Examples + -------- + >>> SessionId("$0").render() + '$0' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("$"): + msg = f"SessionId must start with '$', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class ClientName: + """A tmux client name (a tty path such as ``/dev/pts/3``). + + Examples + -------- + >>> ClientName("/dev/pts/3").render() + '/dev/pts/3' + """ + + value: str + + def __post_init__(self) -> None: + """Reject an empty client name (fail closed).""" + if not self.value: + msg = "ClientName must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class NameRef: + """A target addressed by name, optionally requiring an exact match. + + tmux matches names as a prefix by default; prefixing with ``=`` forces an + exact match. + + Examples + -------- + >>> NameRef("work").render() + 'work' + >>> NameRef("work", exact=True).render() + '=work' + """ + + name: str + exact: bool = False + + def __post_init__(self) -> None: + """Reject an empty name (fail closed).""" + if not self.name: + msg = "NameRef name must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return f"={self.name}" if self.exact else self.name + + +@dataclass(frozen=True, slots=True) +class IndexRef: + """A target addressed by integer index (window or session index). + + Examples + -------- + >>> IndexRef(0).render() + '0' + >>> IndexRef(2, parent="$1").render() + '$1:2' + """ + + index: int + parent: str | None = None + + def render(self) -> str: + """Render as a tmux ``-t`` target token, qualified by parent if set.""" + if self.parent is not None: + return f"{self.parent}:{self.index}" + return str(self.index) + + +@dataclass(frozen=True, slots=True) +class Special: + """A tmux special target token, e.g. ``{marked}``, ``{last}``, ``{up-of}``. + + The token vocabulary comes from tmux's target-resolution tables (see + ``cmd-find.c``). This wrapper keeps a symbolic target distinct from a + concrete id at the type level. + + Examples + -------- + >>> Special("{marked}").render() + '{marked}' + >>> Special("last").render() + 'last' + """ + + token: str + + def __post_init__(self) -> None: + """Reject an empty token (fail closed).""" + if not self.token: + msg = "Special token must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.token + + +@dataclass(frozen=True, slots=True) +class SlotRef: + """A deferred target: "the id of forward slot N", filled at resolve time. + + Carried by an operation built against an object that does not exist yet in + a multi-operation plan. A resolver replaces it with the captured concrete + id plus ``suffix`` before the operation renders; rendering an unresolved + :class:`SlotRef` is a planner bug and raises (see + :meth:`libtmux.experimental.ops.operation.Operation.render`). ``suffix`` + lets a command needing a qualified target -- e.g. ``new-window -t $N:`` -- + reuse a plain captured ``$N``. + + Examples + -------- + >>> SlotRef(0) + SlotRef(slot=0, suffix='') + >>> SlotRef(0, ":") + SlotRef(slot=0, suffix=':') + """ + + slot: int + suffix: str = "" + + def render(self) -> str: + """Raise -- an unresolved deferred ref cannot be rendered.""" + msg = "cannot render an unresolved SlotRef; resolve the plan first" + raise TypeError(msg) + + +Target: t.TypeAlias = ( + "PaneId | WindowId | SessionId | ClientName | NameRef | IndexRef " + "| Special | SlotRef" +) +"""The closed sum of everything that can appear as an operation ``-t`` target.""" + + +def render_target(target: Target | None) -> str | None: + """Render any :data:`Target` to its tmux token, or ``None`` for no target. + + Parameters + ---------- + target : Target or None + The typed target to render. + + Returns + ------- + str or None + The ``-t`` token string, or ``None`` when there is no target. + + Examples + -------- + >>> render_target(PaneId("%1")) + '%1' + >>> render_target(None) is None + True + """ + if target is None: + return None + return target.render() diff --git a/src/libtmux/experimental/ops/exc.py b/src/libtmux/experimental/ops/exc.py new file mode 100644 index 000000000..e9a87ee2e --- /dev/null +++ b/src/libtmux/experimental/ops/exc.py @@ -0,0 +1,121 @@ +"""Exceptions for :mod:`libtmux.experimental.ops`. + +All exceptions subclass :class:`libtmux.exc.LibTmuxException`, so existing +``except LibTmuxException`` handlers keep working while the operation layer +stays isolated under :mod:`libtmux.experimental`. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.exc import LibTmuxException + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +class OperationError(LibTmuxException): + """Base class for problems building or registering operations.""" + + +class UnknownOperation(OperationError): + """No operation is registered under the requested ``kind``. + + Examples + -------- + >>> raise UnknownOperation("split_window") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.UnknownOperation: no operation registered for + kind 'split_window' + """ + + def __init__(self, kind: str) -> None: + self.kind = kind + msg = f"no operation registered for kind {kind!r}" + super().__init__(msg) + + +class DuplicateOperation(OperationError): + """An operation ``kind`` is already registered. + + Examples + -------- + >>> raise DuplicateOperation("split_window") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.DuplicateOperation: an operation is already + registered for kind 'split_window' + """ + + def __init__(self, kind: str) -> None: + self.kind = kind + msg = f"an operation is already registered for kind {kind!r}" + super().__init__(msg) + + +class VersionUnsupported(OperationError): + """An operation cannot render against the given tmux version. + + Examples + -------- + >>> raise VersionUnsupported("split_window", need="3.0", have="2.9") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.VersionUnsupported: operation 'split_window' + requires tmux >= 3.0 (have 2.9) + """ + + def __init__(self, kind: str, *, need: str, have: str) -> None: + self.kind = kind + self.need = need + self.have = have + msg = f"operation {kind!r} requires tmux >= {need} (have {have})" + super().__init__(msg) + + +class TmuxCommandError(LibTmuxException): + """A tmux command reported failure, raised by ``Result.raise_for_status()``. + + The constructor mirrors :class:`subprocess.CalledProcessError`'s argument + order (``returncode``, ``cmd``, ``stdout``, ``stderr``) so the failure + surface is familiar to anyone who has handled a subprocess error. + + Parameters + ---------- + returncode : int + The tmux process exit code (``-1`` when unknown, e.g. a timeout). + cmd : Sequence[str] + The rendered tmux argv that failed. + stdout : Sequence[str], optional + Captured stdout lines. + stderr : Sequence[str], optional + Captured stderr lines. + + Examples + -------- + >>> raise TmuxCommandError(1, ["split-window", "-t", "%999"], + ... stderr=["can't find pane %999"]) + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.TmuxCommandError: tmux 'split-window -t %999' + failed (exit 1): can't find pane %999 + """ + + def __init__( + self, + returncode: int, + cmd: Sequence[str], + stdout: Sequence[str] | None = None, + stderr: Sequence[str] | None = None, + ) -> None: + self.returncode = returncode + self.cmd = tuple(cmd) + self.stdout = tuple(stdout) if stdout is not None else () + self.stderr = tuple(stderr) if stderr is not None else () + detail = " ".join(self.stderr).strip() + suffix = f": {detail}" if detail else "" + rendered = " ".join(self.cmd) + msg = f"tmux {rendered!r} failed (exit {returncode}){suffix}" + super().__init__(msg) diff --git a/src/libtmux/experimental/ops/execute.py b/src/libtmux/experimental/ops/execute.py new file mode 100644 index 000000000..c6b12a345 --- /dev/null +++ b/src/libtmux/experimental/ops/execute.py @@ -0,0 +1,115 @@ +"""Execute an operation through an engine and get back its typed result. + +These two helpers are the whole bridge between the inert operation substrate and +the engines. They share the operation's pure :meth:`~.operation.Operation.render` +and :meth:`~.operation.Operation.build_result`; the *only* difference between the +sync and async paths is ``engine.run(...)`` versus ``await engine.run(...)`` -- +the same sans-I/O split the lazy plan resolver uses. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +ResultT = t.TypeVar("ResultT", bound="Result") + + +def run( + operation: Operation[ResultT], + engine: TmuxEngine, + *, + version: str | None = None, + tmux_bin: str | pathlib.Path | None = None, +) -> ResultT: + """Render *operation*, run it through *engine*, return its typed result. + + Parameters + ---------- + operation : Operation + The operation to execute. + engine : TmuxEngine + Any synchronous engine. + version : str or None + tmux version to render against (drops unsupported flags); ``None`` + renders every flag. + tmux_bin : str or pathlib.Path or None + Override the tmux binary for this call. + + Returns + ------- + ResultT + The operation's specialized result. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, run + >>> from libtmux.experimental.ops._types import PaneId + >>> from libtmux.experimental.engines import CommandResult + >>> class EchoEngine: + ... def run(self, request): + ... return CommandResult(cmd=("tmux", *request.args), returncode=0) + ... def run_batch(self, requests): + ... return [self.run(r) for r in requests] + >>> result = run(SendKeys(target=PaneId("%1"), keys="echo hi"), EchoEngine()) + >>> result.ok + True + >>> result.argv + ('send-keys', '-t', '%1', 'echo hi') + """ + rendered = operation.render(version=version) + raw = engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) + return operation.build_result( + argv=rendered, + returncode=raw.returncode, + stdout=raw.stdout, + stderr=raw.stderr, + ) + + +async def arun( + operation: Operation[ResultT], + engine: AsyncTmuxEngine, + *, + version: str | None = None, + tmux_bin: str | pathlib.Path | None = None, +) -> ResultT: + """Async sibling of :func:`run`, sharing the same render/build path. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import arun, CapturePane + >>> from libtmux.experimental.ops._types import PaneId + >>> from libtmux.experimental.engines import CommandResult + >>> class AsyncEchoEngine: + ... async def run(self, request): + ... return CommandResult( + ... cmd=("tmux", *request.args), + ... stdout=("line-1", "line-2"), + ... returncode=0, + ... ) + ... async def run_batch(self, requests): + ... return [await self.run(r) for r in requests] + >>> async def main(): + ... return await arun(CapturePane(target=PaneId("%1")), AsyncEchoEngine()) + >>> result = asyncio.run(main()) + >>> result.lines + ('line-1', 'line-2') + """ + rendered = operation.render(version=version) + raw = await engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) + return operation.build_result( + argv=rendered, + returncode=raw.returncode, + stdout=raw.stdout, + stderr=raw.stderr, + ) diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py new file mode 100644 index 000000000..f2c9b9c07 --- /dev/null +++ b/src/libtmux/experimental/ops/operation.py @@ -0,0 +1,254 @@ +"""The base :class:`Operation` value. + +An operation is an immutable, keyword-only dataclass that carries everything an +engine needs to render a tmux command, validate it against a tmux version, and +type its result -- but it never dispatches. Operation classes declare their +stable metadata (``kind``, ``command``, ``scope``, ``result_cls``, effects, +safety, version gates) as class variables, so the class itself is the single +source of truth that the registry, serializer, and docs catalog all read from. + +Rendering is pure: :meth:`Operation.render` produces an argv tuple from the +operation's fields, dropping version-gated flags an older tmux cannot accept, +and :meth:`Operation.build_result` adapts raw tmux output into the operation's +typed result -- both without touching a tmux server. +""" + +from __future__ import annotations + +import types +import typing as t +from dataclasses import dataclass + +from libtmux._compat import LooseVersion +from libtmux.experimental.ops._types import render_target +from libtmux.experimental.ops.exc import VersionUnsupported +from libtmux.experimental.ops.results import Result, status_for + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from libtmux.experimental.ops._types import ( + Effects, + Safety, + Scope, + Status, + Target, + ) + +ResultT = t.TypeVar("ResultT", bound=Result) + + +@dataclass(frozen=True, kw_only=True) +class Operation(t.Generic[ResultT]): + """Inert, typed description of one tmux command. + + Subclasses declare the class-level metadata and provide :meth:`args` (the + positional tokens after the target). The instance fields describe one + concrete invocation. + + Parameters + ---------- + target : Target or None + The ``-t`` target, or ``None`` for "no explicit target". + + Notes + ----- + Class variables (set by subclasses): + + ``kind`` + Stable discriminator, also the registry key (e.g. ``"split_window"``). + ``command`` + The tmux command name (e.g. ``"split-window"``). + ``scope`` + The tmux object scope (:data:`~._types.Scope`). + ``result_cls`` + The :class:`~.results.Result` subclass this operation returns. + ``chainable`` + Whether the command may be folded into a one-dispatch sequence. + ``primitive`` + ``True`` when the operation wraps one tmux command; ``False`` when it is + composed from others (e.g. a synthesized server-exists check). + ``safety`` + The :data:`~._types.Safety` tier. + ``effects`` + An :class:`~._types.Effects` descriptor. + ``min_version`` + Minimum tmux version the whole operation requires, if any. + ``flag_version_map`` + Maps a feature label to the minimum tmux version that supports it; the + operation consults this in :meth:`args` to drop unsupported flags. + """ + + target: Target | None = None + + kind: t.ClassVar[str] + command: t.ClassVar[str] + scope: t.ClassVar[Scope] + result_cls: t.ClassVar[type[Result]] + chainable: t.ClassVar[bool] = True + primitive: t.ClassVar[bool] = True + safety: t.ClassVar[Safety] = "mutating" + effects: t.ClassVar[Effects] + min_version: t.ClassVar[str | None] = None + flag_version_map: t.ClassVar[Mapping[str, str]] = types.MappingProxyType({}) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Return the positional argument tokens after the target. + + Override per operation. ``version`` is the tmux version the engine will + run against (``None`` means "assume latest"); use :meth:`flag_available` + to drop flags an older tmux cannot accept. + + Returns + ------- + tuple[str, ...] + """ + return () + + def render(self, *, version: str | None = None) -> tuple[str, ...]: + """Render this operation as a tmux argv tuple. + + Parameters + ---------- + version : str or None + The tmux version to render against. ``None`` renders every flag. + + Returns + ------- + tuple[str, ...] + ``(command, ["-t", target], *args)``. + + Raises + ------ + ~libtmux.experimental.ops.exc.VersionUnsupported + When the running tmux is older than :attr:`min_version`. + TypeError + When the target is an unresolved + :class:`~._types.SlotRef` (a planner bug). + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> SendKeys(target=PaneId("%1"), keys="echo hi", enter=True).render() + ('send-keys', '-t', '%1', 'echo hi', 'Enter') + """ + self.check_version(version) + rendered: list[str] = [self.command] + token = render_target(self.target) + if token is not None: + rendered.extend(("-t", token)) + rendered.extend(self.args(version=version)) + return tuple(rendered) + + def check_version(self, version: str | None) -> None: + """Raise if *version* is older than this operation's :attr:`min_version`. + + Parameters + ---------- + version : str or None + The running tmux version, or ``None`` to skip the check. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> SplitWindow(target=PaneId("%1")).check_version("3.4") + """ + if version is None or self.min_version is None: + return + if LooseVersion(version) < LooseVersion(self.min_version): + raise VersionUnsupported( + self.kind, + need=self.min_version, + have=version, + ) + + def flag_available(self, label: str, version: str | None) -> bool: + """Whether a version-gated feature is available on *version*. + + Parameters + ---------- + label : str + A key in :attr:`flag_version_map`. + version : str or None + The running tmux version, or ``None`` to assume latest. + + Returns + ------- + bool + ``True`` when the feature is ungated, unknown, or supported. + + Examples + -------- + >>> from libtmux.experimental.ops import CapturePane + >>> from libtmux.experimental.ops._types import PaneId + >>> op = CapturePane(target=PaneId("%1"), trim_trailing=True) + >>> op.flag_available("trim_trailing", "3.4") + True + >>> op.flag_available("trim_trailing", "3.0") + False + """ + need = self.flag_version_map.get(label) + if need is None or version is None: + return True + return LooseVersion(version) >= LooseVersion(need) + + def build_result( + self, + *, + returncode: int, + argv: tuple[str, ...] | None = None, + stdout: Sequence[str] = (), + stderr: Sequence[str] = (), + version: str | None = None, + ) -> ResultT: + """Adapt raw tmux output into this operation's typed result. + + Parameters + ---------- + returncode : int + tmux exit code. + argv : tuple[str, ...] or None + The argv that produced the output; rendered from this operation when + omitted. + stdout, stderr : Sequence[str] + Captured output lines. + version : str or None + Used only to render *argv* when it is omitted. + + Returns + ------- + ResultT + An instance of :attr:`result_cls`. + """ + rendered = argv if argv is not None else self.render(version=version) + status = status_for(returncode, stderr) + return self._make_result( + rendered, + status, + returncode, + tuple(stdout), + tuple(stderr), + ) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + ) -> ResultT: + """Construct the result instance; override to populate typed payloads.""" + return t.cast( + "ResultT", + self.result_cls( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + ), + ) diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py new file mode 100644 index 000000000..a929d79ef --- /dev/null +++ b/src/libtmux/experimental/ops/registry.py @@ -0,0 +1,207 @@ +"""The operation registry: one entry per operation ``kind``. + +The registry is the single source of truth that runtime dispatch, serialization, +and the (planned) docs catalog all read from. Each entry is an :class:`OpSpec` +derived from an :class:`~.operation.Operation` subclass's class variables, so the +operation class itself remains authoritative -- the registry just indexes it. + +Lookups fail closed: an unknown ``kind`` raises +:class:`~.exc.UnknownOperation`, and registering a duplicate raises +:class:`~.exc.DuplicateOperation` unless ``replace=True``. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.exc import DuplicateOperation, UnknownOperation + +if t.TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping + + from libtmux.experimental.ops._types import Effects, Safety, Scope + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +OpT = t.TypeVar("OpT", bound="type[Operation[t.Any]]") + + +@dataclass(frozen=True) +class OpSpec: + """Indexed metadata for one operation, derived from its class variables. + + Attributes mirror the operation class variables documented on + :class:`~.operation.Operation`. + """ + + kind: str + command: str + scope: Scope + operation_cls: type[Operation[t.Any]] + result_cls: type[Result] + chainable: bool + primitive: bool + safety: Safety + effects: Effects + min_version: str | None + flag_version_map: Mapping[str, str] + + @classmethod + def from_operation(cls, operation_cls: type[Operation[t.Any]]) -> OpSpec: + """Build a spec by reading an operation class's class variables. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> spec = OpSpec.from_operation(SplitWindow) + >>> spec.kind, spec.command, spec.scope + ('split_window', 'split-window', 'window') + """ + return cls( + kind=operation_cls.kind, + command=operation_cls.command, + scope=operation_cls.scope, + operation_cls=operation_cls, + result_cls=operation_cls.result_cls, + chainable=operation_cls.chainable, + primitive=operation_cls.primitive, + safety=operation_cls.safety, + effects=operation_cls.effects, + min_version=operation_cls.min_version, + flag_version_map=operation_cls.flag_version_map, + ) + + +class OperationRegistry: + """A fail-closed index of operations keyed by ``kind``. + + Examples + -------- + >>> from libtmux.experimental.ops import registry, SplitWindow + >>> "split_window" in registry + True + >>> registry.get("split_window").scope + 'window' + >>> registry.operation("split_window") is SplitWindow + True + >>> registry.get("does_not_exist") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.UnknownOperation: no operation registered for + kind 'does_not_exist' + """ + + def __init__(self) -> None: + self._specs: dict[str, OpSpec] = {} + + def register( + self, + operation_cls: type[Operation[t.Any]], + *, + replace: bool = False, + ) -> None: + """Register an operation class. + + Parameters + ---------- + operation_cls : type[Operation] + The operation class to index. + replace : bool + Allow replacing an existing registration for the same ``kind``. + + Raises + ------ + ~libtmux.experimental.ops.exc.DuplicateOperation + When ``kind`` is already registered and ``replace`` is ``False``. + """ + kind = operation_cls.kind + if not replace and kind in self._specs: + raise DuplicateOperation(kind) + self._specs[kind] = OpSpec.from_operation(operation_cls) + + def unregister(self, kind: str) -> None: + """Remove an operation registration. + + Raises + ------ + ~libtmux.experimental.ops.exc.UnknownOperation + When ``kind`` is not registered. + """ + if kind not in self._specs: + raise UnknownOperation(kind) + del self._specs[kind] + + def get(self, kind: str) -> OpSpec: + """Return the :class:`OpSpec` for ``kind`` or fail closed. + + Raises + ------ + ~libtmux.experimental.ops.exc.UnknownOperation + When ``kind`` is not registered. + """ + try: + return self._specs[kind] + except KeyError as error: + raise UnknownOperation(kind) from error + + def operation(self, kind: str) -> type[Operation[t.Any]]: + """Return the operation class registered for ``kind``.""" + return self.get(kind).operation_cls + + def list( + self, + predicate: Callable[[OpSpec], bool] | None = None, + ) -> list[OpSpec]: + """Return registered specs (optionally filtered), sorted by ``kind``. + + Parameters + ---------- + predicate : callable, optional + Keep only specs for which ``predicate(spec)`` is true. + + Examples + -------- + >>> from libtmux.experimental.ops import registry + >>> [s.kind for s in registry.list(lambda s: s.safety == "readonly")] + ['capture_pane'] + """ + specs = sorted(self._specs.values(), key=lambda spec: spec.kind) + if predicate is None: + return specs + return [spec for spec in specs if predicate(spec)] + + def kinds(self) -> tuple[str, ...]: + """Return all registered kinds, sorted.""" + return tuple(sorted(self._specs)) + + def __contains__(self, kind: object) -> bool: + """Whether ``kind`` is registered.""" + return kind in self._specs + + def __iter__(self) -> Iterator[OpSpec]: + """Iterate specs sorted by ``kind``.""" + return iter(self.list()) + + def __len__(self) -> int: + """Return the number of registered operations.""" + return len(self._specs) + + +registry = OperationRegistry() +"""The default, process-wide operation registry.""" + + +def register(operation_cls: OpT) -> OpT: + """Class decorator that registers an operation in the default registry. + + Returns the class unchanged, so it can decorate a class definition. + + Examples + -------- + >>> from libtmux.experimental.ops import registry + >>> "send_keys" in registry + True + """ + registry.register(operation_cls) + return operation_cls diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py new file mode 100644 index 000000000..70951a4e9 --- /dev/null +++ b/src/libtmux/experimental/ops/results.py @@ -0,0 +1,170 @@ +"""Typed result values for :mod:`libtmux.experimental.ops`. + +A :class:`Result` is the uniform shape every engine returns for the same +operation: the operation that produced it, the rendered argv, an execution +:data:`~libtmux.experimental.ops._types.Status`, and the captured tmux output. +Specialized payloads (a new pane id, captured lines) live on subclasses defined +next to their operations. + +Results never raise on construction. Raising is opt-in via +:meth:`Result.raise_for_status`, mirroring +:meth:`subprocess.CompletedProcess.check_returncode`. *How* an engine treats a +failed result is the engine's policy: the classic engine raises in its facade to +match today's behavior, while newer engines hand the result back and let the +caller decide. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops.exc import TmuxCommandError + +if t.TYPE_CHECKING: + from typing_extensions import Self + + from libtmux.experimental.ops._types import Status + from libtmux.experimental.ops.operation import Operation + + +def status_for(returncode: int, stderr: t.Sequence[str]) -> Status: + """Derive a result :data:`~._types.Status` from a tmux outcome. + + tmux frequently reports a failure as stderr text while still exiting ``0``, + so non-empty stderr counts as a failure here -- a deliberate divergence from + a returncode-only test (see :meth:`Result.raise_for_status`). + + Parameters + ---------- + returncode : int + The tmux process exit code. + stderr : Sequence[str] + Captured stderr lines. + + Returns + ------- + Status + + Examples + -------- + >>> status_for(0, []) + 'complete' + >>> status_for(1, []) + 'failed' + >>> status_for(0, ["no current session"]) + 'failed' + """ + if returncode == 0 and not stderr: + return "complete" + return "failed" + + +@dataclass(frozen=True) +class Result: + """Base result for an executed (or simulated) operation. + + Parameters + ---------- + operation : Operation + The operation this result came from. + argv : tuple[str, ...] + The rendered tmux argv that produced this result. + status : Status + Execution status. + returncode : int + tmux exit code (``-1`` when unknown, e.g. a timeout). + stdout, stderr : tuple[str, ...] + Captured output lines. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> result = SendKeys(target=PaneId("%1"), keys="echo hi").build_result( + ... argv=("send-keys", "-t", "%1", "echo hi"), + ... returncode=0, + ... ) + >>> result.ok + True + >>> result.raise_for_status() is result + True + + A failed result raises only when asked: + + >>> failed = SendKeys(target=PaneId("%9"), keys="x").build_result( + ... argv=("send-keys", "-t", "%9", "x"), + ... returncode=1, + ... stderr=("can't find pane %9",), + ... ) + >>> failed.ok + False + >>> failed.raise_for_status() + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.TmuxCommandError: tmux 'send-keys -t %9 x' + failed (exit 1): can't find pane %9 + """ + + operation: Operation[t.Any] + argv: tuple[str, ...] + status: Status + returncode: int + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + + @property + def ok(self) -> bool: + """Whether the operation completed successfully.""" + return self.status == "complete" + + @property + def failed(self) -> bool: + """Whether the operation ran and tmux reported failure.""" + return self.status == "failed" + + def raise_for_status(self) -> Self: + """Raise :class:`~.exc.TmuxCommandError` if the result is not OK. + + Returns ``self`` on success so it can be used fluently + (``result = run(op, engine).raise_for_status()``). A ``failed`` or + ``unknown`` status raises; ``complete`` and ``skipped`` do not. + + Returns + ------- + Self + + Raises + ------ + ~libtmux.experimental.ops.exc.TmuxCommandError + When :attr:`status` is ``failed`` or ``unknown``. + """ + if self.status in {"failed", "unknown"}: + raise TmuxCommandError( + self.returncode, + self.argv, + self.stdout, + self.stderr, + ) + return self + + +@dataclass(frozen=True) +class SplitWindowResult(Result): + """Result of a ``split-window`` operation. + + Adds the id of the pane tmux created, when it was captured. + """ + + new_pane_id: str | None = None + + +@dataclass(frozen=True) +class CapturePaneResult(Result): + """Result of a ``capture-pane`` operation. + + Adds the captured pane lines as :attr:`lines` (also available as + :attr:`stdout`). + """ + + lines: tuple[str, ...] = field(default_factory=tuple) diff --git a/src/libtmux/experimental/ops/serialize.py b/src/libtmux/experimental/ops/serialize.py new file mode 100644 index 000000000..d65dff506 --- /dev/null +++ b/src/libtmux/experimental/ops/serialize.py @@ -0,0 +1,181 @@ +"""Serialize operations and results to/from plain dicts. + +Serialized payloads contain only stable, JSON-friendly data -- a ``kind`` +discriminator, target descriptors, scalar fields, and captured output. They hold +no live :class:`~libtmux.Server`/:class:`~libtmux.Pane`, subprocess handles, or +event-loop objects, so an operation built in one process can be reconstructed in +another. Reconstruction goes through the registry, so only registered operations +can be revived (fail closed). +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.registry import registry + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +_TARGET_TYPES: dict[str, type] = { + cls.__name__: cls + for cls in ( + PaneId, + WindowId, + SessionId, + ClientName, + NameRef, + IndexRef, + Special, + SlotRef, + ) +} + + +def target_to_dict(target: Target | None) -> dict[str, t.Any] | None: + """Serialize a :data:`~._types.Target` to a tagged dict (or ``None``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> target_to_dict(PaneId("%1")) + {'type': 'PaneId', 'value': '%1'} + >>> target_to_dict(None) is None + True + """ + if target is None: + return None + return {"type": type(target).__name__, **dataclasses.asdict(target)} + + +def target_from_dict(data: Mapping[str, t.Any] | None) -> Target | None: + """Reconstruct a :data:`~._types.Target` from :func:`target_to_dict` output. + + Examples + -------- + >>> target_from_dict({"type": "PaneId", "value": "%1"}) + PaneId(value='%1') + >>> target_from_dict(None) is None + True + """ + if data is None: + return None + cls = _TARGET_TYPES[data["type"]] + fields = {key: value for key, value in data.items() if key != "type"} + return t.cast("Target", cls(**fields)) + + +def _jsonify(value: t.Any) -> t.Any: + """Render a field value as JSON-friendly data.""" + if isinstance(value, tuple): + return list(value) + if isinstance(value, dict): + return dict(value) + return value + + +def operation_to_dict(operation: Operation[t.Any]) -> dict[str, t.Any]: + """Serialize an operation to a plain dict. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> data = operation_to_dict(SplitWindow(target=PaneId("%1"), horizontal=True)) + >>> data["kind"], data["target"], data["horizontal"] + ('split_window', {'type': 'PaneId', 'value': '%1'}, True) + """ + data: dict[str, t.Any] = {"kind": operation.kind} + for field in dataclasses.fields(operation): + value = getattr(operation, field.name) + if field.name == "target": + data["target"] = target_to_dict(value) + else: + data[field.name] = _jsonify(value) + return data + + +def operation_from_dict(data: Mapping[str, t.Any]) -> Operation[t.Any]: + """Reconstruct an operation from :func:`operation_to_dict` output. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> op = SplitWindow(target=PaneId("%1"), horizontal=True) + >>> operation_from_dict(operation_to_dict(op)) == op + True + """ + operation_cls = registry.operation(data["kind"]) + kwargs: dict[str, t.Any] = {} + for field in dataclasses.fields(operation_cls): + if field.name not in data: + continue + if field.name == "target": + kwargs["target"] = target_from_dict(data["target"]) + else: + kwargs[field.name] = data[field.name] + return operation_cls(**kwargs) + + +def _coerce_field(value: t.Any) -> t.Any: + """Coerce a JSON list back into the tuple a result field expects.""" + if isinstance(value, list): + return tuple(value) + return value + + +def result_to_dict(result: Result) -> dict[str, t.Any]: + """Serialize a result (and its operation) to a plain dict. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> r = SplitWindow(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> data = result_to_dict(r) + >>> data["status"], data["new_pane_id"] + ('complete', '%2') + """ + data: dict[str, t.Any] = {"operation": operation_to_dict(result.operation)} + for field in dataclasses.fields(result): + if field.name == "operation": + continue + data[field.name] = _jsonify(getattr(result, field.name)) + return data + + +def result_from_dict(data: Mapping[str, t.Any]) -> Result: + """Reconstruct a result from :func:`result_to_dict` output. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> r = SplitWindow(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> result_from_dict(result_to_dict(r)) == r + True + """ + operation = operation_from_dict(data["operation"]) + result_cls = type(operation).result_cls + kwargs: dict[str, t.Any] = {"operation": operation} + for field in dataclasses.fields(result_cls): + if field.name == "operation" or field.name not in data: + continue + kwargs[field.name] = _coerce_field(data[field.name]) + return result_cls(**kwargs) diff --git a/tests/experimental/__init__.py b/tests/experimental/__init__.py new file mode 100644 index 000000000..a65199c59 --- /dev/null +++ b/tests/experimental/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.""" + +from __future__ import annotations diff --git a/tests/experimental/ops/__init__.py b/tests/experimental/ops/__init__.py new file mode 100644 index 000000000..ae42924a7 --- /dev/null +++ b/tests/experimental/ops/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.ops.""" + +from __future__ import annotations diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py new file mode 100644 index 000000000..017ad6ba1 --- /dev/null +++ b/tests/experimental/ops/test_execute.py @@ -0,0 +1,107 @@ +"""Tests for the :func:`run` / :func:`arun` execution bridge. + +These use in-memory fake engines so they need no tmux server -- the same +property that lets the contract suite run an operation through every engine. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.ops import SendKeys, SplitWindow, arun, run +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import TmuxCommandError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class FakeEngine: + """A synchronous fake engine that echoes argv and a canned stdout.""" + + def __init__(self, stdout: tuple[str, ...] = (), returncode: int = 0) -> None: + self.stdout = stdout + self.returncode = returncode + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> t.Any: + """Record the request and return a canned result.""" + from libtmux.experimental.engines.base import CommandResult + + self.calls.append(request.args) + return CommandResult( + cmd=("tmux", *request.args), + stdout=self.stdout, + stderr=() if self.returncode == 0 else ("boom",), + returncode=self.returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[t.Any]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +class AsyncFakeEngine: + """An asynchronous fake engine mirroring :class:`FakeEngine`.""" + + def __init__(self, stdout: tuple[str, ...] = (), returncode: int = 0) -> None: + self.stdout = stdout + self.returncode = returncode + + async def run(self, request: CommandRequest) -> t.Any: + """Return a canned result asynchronously.""" + from libtmux.experimental.engines.base import CommandResult + + return CommandResult( + cmd=("tmux", *request.args), + stdout=self.stdout, + returncode=self.returncode, + ) + + async def run_batch(self, requests: Sequence[CommandRequest]) -> list[t.Any]: + """Execute each request in order.""" + return [await self.run(req) for req in requests] + + +def test_run_returns_typed_result() -> None: + """``run`` renders, dispatches, and returns the operation's typed result.""" + engine = FakeEngine(stdout=("%9",)) + result = run(SplitWindow(target=WindowId("@1")), engine) + assert result.new_pane_id == "%9" + assert result.argv == ("split-window", "-t", "@1", "-v", "-P", "-F", "#{pane_id}") + assert engine.calls == [result.argv] + + +def test_run_does_not_raise_on_failure() -> None: + """A tmux failure is data on the result; ``run`` itself never raises.""" + engine = FakeEngine(returncode=1) + result = run(SendKeys(target=PaneId("%9"), keys="x"), engine) + assert result.failed + with pytest.raises(TmuxCommandError): + result.raise_for_status() + + +def test_run_version_threads_through() -> None: + """The ``version`` argument reaches operation rendering.""" + from libtmux.experimental.ops import CapturePane + + engine = FakeEngine() + result = run( + CapturePane(target=PaneId("%1"), trim_trailing=True), + engine, + version="3.3", + ) + assert "-T" not in result.argv + + +def test_arun_shares_render_and_build() -> None: + """``arun`` produces the same typed result as ``run`` via the async path.""" + engine = AsyncFakeEngine(stdout=("%5",)) + result = asyncio.run(arun(SplitWindow(target=WindowId("@1")), engine)) + assert result.new_pane_id == "%5" + assert result.ok diff --git a/tests/experimental/ops/test_operation.py b/tests/experimental/ops/test_operation.py new file mode 100644 index 000000000..80320c732 --- /dev/null +++ b/tests/experimental/ops/test_operation.py @@ -0,0 +1,105 @@ +"""Tests for the base :class:`~libtmux.experimental.ops.operation.Operation`.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +import pytest + +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import Effects, PaneId, WindowId +from libtmux.experimental.ops.exc import VersionUnsupported +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True, kw_only=True) +class _FutureOp(Operation[Result]): + """A synthetic operation gated to a future tmux version, for tests.""" + + kind = "_future_op_test" + command = "future-cmd" + scope = "server" + result_cls = Result + effects = Effects() + min_version = "99.0" + + +def test_render_includes_target_then_args() -> None: + """``render`` emits ``command -t target *args`` in order.""" + op = SendKeys(target=PaneId("%1"), keys="echo hi", enter=True) + assert op.render() == ("send-keys", "-t", "%1", "echo hi", "Enter") + + +def test_render_without_target() -> None: + """An operation with no target omits ``-t``.""" + op = SelectLayout(layout="tiled") + assert op.render() == ("select-layout", "tiled") + + +def test_version_gate_drops_unsupported_flag() -> None: + """A version-gated flag is dropped on an older tmux and kept on a newer one.""" + op = CapturePane(target=PaneId("%1"), trim_trailing=True) + assert op.render(version="3.3") == ("capture-pane", "-t", "%1", "-p") + assert op.render(version="3.4") == ("capture-pane", "-t", "%1", "-p", "-T") + assert op.render() == ("capture-pane", "-t", "%1", "-p", "-T") + + +def test_check_version_raises_when_too_low() -> None: + """An operation older tmux cannot satisfy raises on render.""" + op = _FutureOp() + with pytest.raises(VersionUnsupported, match="requires tmux >= 99"): + op.render(version="3.4") + + +def test_check_version_passes_when_satisfied() -> None: + """No version (or a satisfying one) renders without error.""" + op = _FutureOp() + assert op.render() == ("future-cmd",) + assert op.render(version="99.0") == ("future-cmd",) + + +def test_build_result_parses_payload() -> None: + """``split-window`` parses the captured new-pane id into its result.""" + op = SplitWindow(target=WindowId("@1")) + result = op.build_result(returncode=0, stdout=("%7",)) + assert result.new_pane_id == "%7" + assert result.ok + assert result.operation is op + + +def test_build_result_failure_status() -> None: + """A nonzero return code yields a ``failed`` result and no payload.""" + op = SplitWindow(target=WindowId("@1")) + result = op.build_result(returncode=1, stderr=("no space for new pane",)) + assert result.status == "failed" + assert result.new_pane_id is None + + +def test_operations_are_frozen() -> None: + """Operations are immutable values.""" + op = SendKeys(target=PaneId("%1"), keys="x") + with pytest.raises((AttributeError, TypeError)): + op.keys = "y" # type: ignore[misc] + + +@pytest.mark.parametrize( + "op", + [ + pytest.param(SplitWindow(target=WindowId("@1")), id="split_window"), + pytest.param(CapturePane(target=PaneId("%1")), id="capture_pane"), + pytest.param(SendKeys(target=PaneId("%1"), keys="x"), id="send_keys"), + pytest.param(SelectLayout(target=WindowId("@1")), id="select_layout"), + ], +) +def test_render_is_nonempty_argv(op: Operation[t.Any]) -> None: + """Every seed operation renders to a non-empty argv starting with command.""" + argv = op.render() + assert argv + assert argv[0] == op.command diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py new file mode 100644 index 000000000..b87261dd7 --- /dev/null +++ b/tests/experimental/ops/test_registry.py @@ -0,0 +1,74 @@ +"""Tests for the operation registry.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.ops import SplitWindow, registry +from libtmux.experimental.ops.exc import DuplicateOperation, UnknownOperation +from libtmux.experimental.ops.registry import OperationRegistry, OpSpec + + +def test_seed_operations_registered() -> None: + """All seed operations are present in the default registry.""" + assert set(registry.kinds()) >= { + "split_window", + "capture_pane", + "send_keys", + "select_layout", + } + + +def test_get_unknown_fails_closed() -> None: + """Looking up an unregistered kind raises :class:`UnknownOperation`.""" + with pytest.raises(UnknownOperation, match="does_not_exist"): + registry.get("does_not_exist") + + +def test_operation_lookup_returns_class() -> None: + """``operation`` returns the registered class for a kind.""" + assert registry.operation("split_window") is SplitWindow + + +def test_spec_from_operation_reads_classvars() -> None: + """An :class:`OpSpec` mirrors the operation's class variables.""" + spec = OpSpec.from_operation(SplitWindow) + assert spec.kind == "split_window" + assert spec.command == "split-window" + assert spec.scope == "window" + assert spec.result_cls is SplitWindow.result_cls + assert spec.effects.creates == "pane" + + +def test_list_predicate_filters() -> None: + """``list`` filters by a predicate and stays sorted by kind.""" + readonly = [ + spec.kind for spec in registry.list(lambda spec: spec.safety == "readonly") + ] + assert readonly == ["capture_pane"] + + +def test_register_duplicate_fails_closed() -> None: + """Registering an existing kind raises unless ``replace=True``.""" + local = OperationRegistry() + local.register(SplitWindow) + with pytest.raises(DuplicateOperation, match="split_window"): + local.register(SplitWindow) + local.register(SplitWindow, replace=True) + assert "split_window" in local + + +def test_unregister() -> None: + """Unregistering removes the kind; unregistering a missing kind raises.""" + local = OperationRegistry() + local.register(SplitWindow) + local.unregister("split_window") + assert "split_window" not in local + with pytest.raises(UnknownOperation): + local.unregister("split_window") + + +def test_len_and_iter() -> None: + """The default registry is sized and iterable in kind order.""" + assert len(registry) == len(registry.kinds()) + assert [spec.kind for spec in registry] == sorted(registry.kinds()) diff --git a/tests/experimental/ops/test_results.py b/tests/experimental/ops/test_results.py new file mode 100644 index 000000000..cfb43ebc2 --- /dev/null +++ b/tests/experimental/ops/test_results.py @@ -0,0 +1,73 @@ +"""Tests for results and the opt-in failure model.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.ops import SendKeys +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.exc import TmuxCommandError +from libtmux.experimental.ops.results import Result, status_for + + +@pytest.mark.parametrize( + ("returncode", "stderr", "expected"), + [ + pytest.param(0, [], "complete", id="clean"), + pytest.param(1, [], "failed", id="nonzero"), + pytest.param(0, ["no current session"], "failed", id="stderr-on-zero"), + ], +) +def test_status_for(returncode: int, stderr: list[str], expected: str) -> None: + """Tmux signalling failure via stderr counts as failed even on exit 0.""" + assert status_for(returncode, stderr) == expected + + +def _result(returncode: int, stderr: tuple[str, ...] = ()) -> Result: + """Build a send-keys result for the given outcome.""" + return SendKeys(target=PaneId("%1"), keys="x").build_result( + returncode=returncode, + stderr=stderr, + ) + + +def test_ok_result_does_not_raise() -> None: + """``raise_for_status`` returns the result itself when OK (fluent).""" + result = _result(0) + assert result.ok + assert result.raise_for_status() is result + + +def test_failed_result_raises_typed_error() -> None: + """A failed result raises :class:`TmuxCommandError` only when asked.""" + result = _result(1, ("can't find pane",)) + assert result.failed + with pytest.raises(TmuxCommandError) as excinfo: + result.raise_for_status() + assert excinfo.value.returncode == 1 + assert excinfo.value.stderr == ("can't find pane",) + + +def test_unknown_status_raises() -> None: + """An ``unknown`` (e.g. timeout) result also raises on demand.""" + base = _result(0) + unknown = Result( + operation=base.operation, + argv=base.argv, + status="unknown", + returncode=-1, + ) + with pytest.raises(TmuxCommandError): + unknown.raise_for_status() + + +def test_skipped_status_does_not_raise() -> None: + """A ``skipped`` operation is not a failure.""" + base = _result(0) + skipped = Result( + operation=base.operation, + argv=base.argv, + status="skipped", + returncode=0, + ) + assert skipped.raise_for_status() is skipped diff --git a/tests/experimental/ops/test_serialize.py b/tests/experimental/ops/test_serialize.py new file mode 100644 index 000000000..7c70e6520 --- /dev/null +++ b/tests/experimental/ops/test_serialize.py @@ -0,0 +1,106 @@ +"""Tests for operation/result serialization round-trips.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + Special, + WindowId, +) +from libtmux.experimental.ops.exc import UnknownOperation +from libtmux.experimental.ops.serialize import ( + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + target_from_dict, + target_to_dict, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + +_OPERATIONS = [ + pytest.param( + SplitWindow( + target=PaneId("%1"), + horizontal=True, + start_directory="/tmp", + environment={"FOO": "bar"}, + ), + id="split_window-full", + ), + pytest.param(CapturePane(target=PaneId("%2"), start=0, end=10), id="capture_pane"), + pytest.param( + SendKeys(target=PaneId("%3"), keys="echo hi", enter=True), + id="send_keys", + ), + pytest.param( + SelectLayout(target=WindowId("@4"), layout="tiled"), id="select_layout" + ), + pytest.param(SplitWindow(), id="split_window-no-target"), +] + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_operation_round_trip(operation: Operation[t.Any]) -> None: + """An operation survives a dict round-trip unchanged.""" + assert operation_from_dict(operation_to_dict(operation)) == operation + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_operation_dict_is_plain_data(operation: Operation[t.Any]) -> None: + """A serialized operation holds only stable, JSON-friendly scalars.""" + data = operation_to_dict(operation) + assert data["kind"] == operation.kind + assert isinstance(data["target"], (dict, type(None))) + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_result_round_trip(operation: Operation[t.Any]) -> None: + """A result (with its operation and payload) survives a dict round-trip.""" + result = operation.build_result(returncode=0, stdout=("%9",)) + assert result_from_dict(result_to_dict(result)) == result + + +@pytest.mark.parametrize( + "target", + [ + pytest.param(PaneId("%1"), id="pane"), + pytest.param(WindowId("@1"), id="window"), + pytest.param(SessionId("$1"), id="session"), + pytest.param(ClientName("/dev/pts/1"), id="client"), + pytest.param(NameRef("work", exact=True), id="name"), + pytest.param(IndexRef(2, parent="$1"), id="index"), + pytest.param(Special("{marked}"), id="special"), + ], +) +def test_target_round_trip(target: Target) -> None: + """Every target type survives a dict round-trip.""" + assert target_from_dict(target_to_dict(target)) == target + + +def test_target_none_round_trip() -> None: + """A missing target round-trips as ``None``.""" + assert target_from_dict(target_to_dict(None)) is None + + +def test_from_dict_unknown_kind_fails_closed() -> None: + """Reviving an unregistered kind raises :class:`UnknownOperation`.""" + with pytest.raises(UnknownOperation): + operation_from_dict({"kind": "does_not_exist"}) diff --git a/tests/experimental/ops/test_types.py b/tests/experimental/ops/test_types.py new file mode 100644 index 000000000..3c72af786 --- /dev/null +++ b/tests/experimental/ops/test_types.py @@ -0,0 +1,82 @@ +"""Tests for the typed primitives in :mod:`libtmux.experimental.ops._types`.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops._types import ( + ClientName, + Effects, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, + render_target, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Target + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + pytest.param(PaneId("%1"), "%1", id="pane-id"), + pytest.param(WindowId("@2"), "@2", id="window-id"), + pytest.param(SessionId("$0"), "$0", id="session-id"), + pytest.param(ClientName("/dev/pts/3"), "/dev/pts/3", id="client-name"), + pytest.param(NameRef("work"), "work", id="name-ref"), + pytest.param(NameRef("work", exact=True), "=work", id="name-ref-exact"), + pytest.param(IndexRef(0), "0", id="index-ref"), + pytest.param(IndexRef(2, parent="$1"), "$1:2", id="index-ref-parent"), + pytest.param(Special("{marked}"), "{marked}", id="special"), + ], +) +def test_target_render(target: Target, expected: str) -> None: + """Each concrete target renders to its tmux ``-t`` token.""" + assert target.render() == expected + assert render_target(target) == expected + + +def test_render_target_none() -> None: + """``render_target(None)`` yields ``None`` (no target).""" + assert render_target(None) is None + + +@pytest.mark.parametrize( + ("ctor", "value"), + [ + pytest.param(PaneId, "1", id="pane-missing-sigil"), + pytest.param(WindowId, "2", id="window-missing-sigil"), + pytest.param(SessionId, "0", id="session-missing-sigil"), + pytest.param(ClientName, "", id="client-empty"), + pytest.param(NameRef, "", id="name-empty"), + pytest.param(Special, "", id="special-empty"), + ], +) +def test_target_validation_fails_closed( + ctor: t.Callable[[str], object], + value: str, +) -> None: + """Malformed targets raise at construction rather than at tmux time.""" + with pytest.raises(ValueError, match="must"): + ctor(value) + + +def test_slot_ref_render_raises() -> None: + """An unresolved deferred ref cannot render -- that is a planner bug.""" + with pytest.raises(TypeError, match="unresolved SlotRef"): + SlotRef(0).render() + + +def test_effects_defaults() -> None: + """An empty :class:`Effects` is all-false / no-creates.""" + effects = Effects() + assert not effects.read_only + assert not effects.destructive + assert effects.creates is None From 6d448b720ae57b1028a3f3218b19764868789979 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:08:01 -0500 Subject: [PATCH 002/223] Ops(feat): Add classic + concrete engines and contract suite why: Proves the operation/result contract is transport-agnostic -- the same typed result whether produced by a real tmux subprocess or an in-memory simulator -- and provides the offline engine that lets ops doctests and tests run without a tmux server (issue 689 phases 2-3). what: - engines.subprocess: classic SubprocessEngine mirroring tmux_cmd (has-session stderr fold, backslashreplace, trailing-blank strip; tmux failure returned as data, only missing binary raises), with for_server() deriving -L/-S/-f/-2 flags from a live Server - engines.concrete: deterministic in-memory engine (fabricated pane/ window/session ids, canned capture lines) for tests and docs - engines.registry: name-keyed engine registry (register/create/ available), seeded with subprocess + concrete - tests/experimental/contract: engine-agnostic operation contract run offline via concrete, plus classic-vs-concrete parity against a real tmux server (same result type + argv, payload may differ) --- src/libtmux/experimental/engines/__init__.py | 18 ++- src/libtmux/experimental/engines/concrete.py | 76 +++++++++++ src/libtmux/experimental/engines/registry.py | 68 ++++++++++ .../experimental/engines/subprocess.py | 120 ++++++++++++++++++ tests/experimental/contract/__init__.py | 3 + .../contract/test_classic_engine.py | 96 ++++++++++++++ .../contract/test_engine_contract.py | 67 ++++++++++ 7 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 src/libtmux/experimental/engines/concrete.py create mode 100644 src/libtmux/experimental/engines/registry.py create mode 100644 src/libtmux/experimental/engines/subprocess.py create mode 100644 tests/experimental/contract/__init__.py create mode 100644 tests/experimental/contract/test_classic_engine.py create mode 100644 tests/experimental/contract/test_engine_contract.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index fa9961319..96f8068db 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -3,9 +3,9 @@ An *engine* executes a rendered tmux command and returns a structured result. Engines are interchangeable behind the :class:`~.base.TmuxEngine` / :class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run -through a subprocess, a persistent ``tmux -C`` control connection, an async -transport, an in-memory simulator, or (as an easter egg) tmux's native binary -peer protocol -- and return the *same* typed result. +through a subprocess (classic), an in-memory simulator (concrete), a persistent +``tmux -C`` control connection, an async transport, or (as an easter egg) tmux's +native binary peer protocol -- and return the *same* typed result. See the operationalization plan (``tmux-python/libtmux`` issue 689). """ @@ -20,12 +20,24 @@ EngineSpec, TmuxEngine, ) +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.registry import ( + available_engines, + create_engine, + register_engine, +) +from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( "AsyncTmuxEngine", "CommandRequest", "CommandResult", + "ConcreteEngine", "EngineKind", "EngineSpec", + "SubprocessEngine", "TmuxEngine", + "available_engines", + "create_engine", + "register_engine", ) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py new file mode 100644 index 000000000..07461d630 --- /dev/null +++ b/src/libtmux/experimental/engines/concrete.py @@ -0,0 +1,76 @@ +"""A deterministic, in-memory engine for tests and docs (no tmux server). + +The concrete engine simulates just enough tmux behaviour to exercise the +operation contract without a live server: creation commands that ask for an id +(``-P -F '#{pane_id}'``) get a fabricated, monotonic id, ``capture-pane`` returns +canned lines, and everything else succeeds with empty output. This is what backs +doctests and the cross-engine contract suite, so examples run anywhere and the +"same typed result regardless of engine" invariant can be asserted offline. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class ConcreteEngine: + """Execute operations against an in-memory simulation. + + Parameters + ---------- + capture_lines : Sequence[str] + Lines that ``capture-pane`` returns. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, CapturePane, run + >>> from libtmux.experimental.ops._types import WindowId, PaneId + >>> engine = ConcreteEngine(capture_lines=("hello", "world")) + >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id + '%1' + >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id + '%2' + >>> run(CapturePane(target=PaneId("%1")), engine).lines + ('hello', 'world') + """ + + def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: + self.capture_lines = tuple(capture_lines) + self._counters = {"pane_id": 0, "window_id": 0, "session_id": 0} + + def _fabricate(self, fmt: str) -> str: + """Return the next fabricated id for a ``#{..._id}`` capture format.""" + for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): + if key in fmt: + self._counters[key] += 1 + return f"{sigil}{self._counters[key]}" + return "?" + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one request against the in-memory simulation.""" + argv = request.args + if "-P" in argv and "-F" in argv: + fmt = argv[argv.index("-F") + 1] + return CommandResult( + cmd=("tmux", *argv), + stdout=(self._fabricate(fmt),), + returncode=0, + ) + if argv and argv[0] == "capture-pane": + return CommandResult( + cmd=("tmux", *argv), + stdout=self.capture_lines, + returncode=0, + ) + return CommandResult(cmd=("tmux", *argv), returncode=0) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order (no batching benefit).""" + return [self.run(req) for req in requests] diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py new file mode 100644 index 000000000..0a7fb10d5 --- /dev/null +++ b/src/libtmux/experimental/engines/registry.py @@ -0,0 +1,68 @@ +"""A name-keyed registry of engine factories. + +Lets engines be created by name (or :class:`~.base.EngineSpec`) so downstream +code and the contract suite can select a transport without importing its class. +Fails closed on an unknown name. Adapted from the ``libtmux-protocol-engines`` +prototype. +""" + +from __future__ import annotations + +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import EngineKind +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.subprocess import SubprocessEngine + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + +EngineFactory = t.Callable[..., "TmuxEngine"] + +_engine_registry: dict[str, EngineFactory] = {} + + +def register_engine(name: str, factory: EngineFactory) -> None: + """Register an engine factory under a name.""" + _engine_registry[name] = factory + + +def available_engines() -> tuple[str, ...]: + """Return registered engine names, sorted. + + Examples + -------- + >>> from libtmux.experimental.engines import available_engines + >>> "concrete" in available_engines() + True + >>> "subprocess" in available_engines() + True + """ + return tuple(sorted(_engine_registry)) + + +def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: + """Instantiate a registered engine by name (fail closed). + + Examples + -------- + >>> from libtmux.experimental.engines import create_engine + >>> create_engine("concrete") + + >>> create_engine("nope") + Traceback (most recent call last): + ... + libtmux.exc.LibTmuxException: unknown tmux engine: nope + """ + engine_name = name.value if isinstance(name, EngineKind) else name + try: + factory = _engine_registry[engine_name] + except KeyError as error: + msg = f"unknown tmux engine: {engine_name}" + raise exc.LibTmuxException(msg) from error + return factory(**kwargs) + + +register_engine(EngineKind.SUBPROCESS.value, SubprocessEngine) +register_engine(EngineKind.CONCRETE.value, ConcreteEngine) diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py new file mode 100644 index 000000000..f76b59c41 --- /dev/null +++ b/src/libtmux/experimental/engines/subprocess.py @@ -0,0 +1,120 @@ +"""The classic subprocess engine. + +Executes tmux via the CLI binary, one fork per command, reproducing today's +:class:`libtmux.common.tmux_cmd` behaviour byte-for-byte: ``backslashreplace`` +decoding, trailing-blank stripping, and the ``has-session`` stderr-into-stdout +fold. A tmux-side failure is returned as data (nonzero ``returncode`` plus +``stderr``); only a missing binary raises. ``server_args`` carries the +connection flags (``-L``/``-S``/``-f``/``-2``) so the engine can target a +specific tmux server. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + +logger = logging.getLogger(__name__) + + +class SubprocessEngine: + """Execute tmux commands by forking the tmux CLI binary. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command (e.g. + ``("-L", "test")`` or ``("-Lmysocket",)``). + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.server_args = tuple(server_args) + self._resolved_bin: str | None = None + + def _resolve_bin(self) -> str: + """Return the tmux binary path, memoized for the engine instance.""" + if self.tmux_bin is not None: + return self.tmux_bin + if self._resolved_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_bin = resolved + return self._resolved_bin + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command via subprocess and return its result.""" + tmux_bin = request.tmux_bin or self._resolve_bin() + cmd = [tmux_bin, *self.server_args, *request.args] + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="backslashreplace", + ) + stdout, stderr = process.communicate() + returncode = process.returncode + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = [line for line in stderr.split("\n") if line] + + if "has-session" in cmd and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order (subprocess forks per call).""" + return [self.run(req) for req in requests] + + @classmethod + def for_server(cls, server: t.Any) -> SubprocessEngine: + """Build an engine bound to a live :class:`libtmux.Server`'s socket. + + Mirrors :meth:`libtmux.Server.cmd`'s connection-flag construction so the + engine talks to the same tmux server as the object API. + """ + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) diff --git a/tests/experimental/contract/__init__.py b/tests/experimental/contract/__init__.py new file mode 100644 index 000000000..5b55edb29 --- /dev/null +++ b/tests/experimental/contract/__init__.py @@ -0,0 +1,3 @@ +"""Cross-engine contract tests.""" + +from __future__ import annotations diff --git a/tests/experimental/contract/test_classic_engine.py b/tests/experimental/contract/test_classic_engine.py new file mode 100644 index 000000000..f3a889646 --- /dev/null +++ b/tests/experimental/contract/test_classic_engine.py @@ -0,0 +1,96 @@ +"""Classic engine against a real tmux server, and parity with concrete. + +These use the libtmux pytest fixtures (a live tmux server), so they exercise the +classic :class:`~libtmux.experimental.engines.subprocess.SubprocessEngine` path +end to end and assert it returns the *same typed result shape* the concrete +engine does. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.results import ( + CapturePaneResult, + Result, + SplitWindowResult, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_classic_split_creates_real_pane(session: Session) -> None: + """A classic split returns a typed result whose new pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + result = run(SplitWindow(target=WindowId(window.window_id)), engine) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert result.new_pane_id.startswith("%") + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_classic_send_keys_and_select_layout(session: Session) -> None: + """Classic send-keys and select-layout return successful typed results.""" + server = session.server + pane = session.active_pane + window = session.active_window + assert pane is not None + assert pane.pane_id is not None + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) + assert type(sent) is Result + assert sent.ok + + laid_out = run( + SelectLayout(target=WindowId(window.window_id), layout="even-horizontal"), + engine, + ) + assert laid_out.ok + + +def test_classic_capture_returns_lines(session: Session) -> None: + """Classic capture-pane returns a typed result carrying line data.""" + server = session.server + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + engine = SubprocessEngine.for_server(server) + + result = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert isinstance(result, CapturePaneResult) + assert result.ok + assert isinstance(result.lines, tuple) + + +def test_classic_concrete_parity(session: Session) -> None: + """Classic and concrete engines agree on result type and argv (not payload).""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + classic = run(operation, SubprocessEngine.for_server(server)) + concrete = run(operation, ConcreteEngine()) + + assert type(classic) is type(concrete) is SplitWindowResult + assert classic.argv == concrete.argv == operation.render() + assert classic.ok and concrete.ok diff --git a/tests/experimental/contract/test_engine_contract.py b/tests/experimental/contract/test_engine_contract.py new file mode 100644 index 000000000..34e60096c --- /dev/null +++ b/tests/experimental/contract/test_engine_contract.py @@ -0,0 +1,67 @@ +"""Engine-agnostic operation contract (runs offline via the concrete engine). + +These assertions hold for *any* engine because they are properties of the +operation executed through the engine: the result is the operation's typed +result class, its argv is the operation's render, and it serializes round-trip. +The concrete engine lets the whole matrix run without a tmux server. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + +_CONTRACT_OPS = [ + pytest.param(SplitWindow(target=WindowId("@1")), id="split_window"), + pytest.param(CapturePane(target=PaneId("%1")), id="capture_pane"), + pytest.param(SendKeys(target=PaneId("%1"), keys="echo hi"), id="send_keys"), + pytest.param( + SelectLayout(target=WindowId("@1"), layout="tiled"), id="select_layout" + ), +] + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_type_matches_operation(operation: Operation[t.Any]) -> None: + """An engine returns the operation's declared result type.""" + result = run(operation, ConcreteEngine()) + assert type(result) is operation.result_cls + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_argv_is_render(operation: Operation[t.Any]) -> None: + """The result's argv equals the operation's pure render.""" + result = run(operation, ConcreteEngine()) + assert result.argv == operation.render() + assert result.ok + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_serialization_round_trip(operation: Operation[t.Any]) -> None: + """A result produced by an engine survives a dict round-trip.""" + result = run(operation, ConcreteEngine()) + assert result_from_dict(result_to_dict(result)) == result + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_same_result_across_engine_instances(operation: Operation[t.Any]) -> None: + """Two fresh engines yield equal typed results -- determinism contract.""" + first = run(operation, ConcreteEngine()) + second = run(operation, ConcreteEngine()) + assert first == second From 0615122434a27eee77ef59e55b054fd0f75d99d7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:14:09 -0500 Subject: [PATCH 003/223] Ops(feat): Add async engine, lazy plans, and op catalog why: Completes the sync/async-symmetric execution story plus the deferred-execution and documentation mechanisms from issue 689 (phase 5 + docs), still without touching any existing API. what: - engines.asyncio: real AsyncSubprocessEngine on create_subprocess_exec (terminates the child on cancellation; not a thread wrapper), mirroring the classic engine's output handling so it returns the same typed result - ops.plan: LazyPlan records operations without touching tmux and resolves SlotRef forward refs at execute time via a sans-I/O generator; sync execute() and async aexecute() share one resolution core (run vs await arun is the only divergence); whole-plan serialization round-trips - ops.catalog: registry-driven CatalogEntry list (scope, version gates, effects, safety, result type, summary) -- the single source a docs domain renders, so runtime and docs cannot drift - tests: lazy resolution sync+async, plan serialization, catalog coverage, async-vs-sync classic parity against a real tmux server --- src/libtmux/experimental/engines/__init__.py | 2 + src/libtmux/experimental/engines/asyncio.py | 129 +++++++++++ src/libtmux/experimental/ops/__init__.py | 6 + src/libtmux/experimental/ops/catalog.py | 91 ++++++++ src/libtmux/experimental/ops/plan.py | 200 ++++++++++++++++++ .../contract/test_async_engine.py | 50 +++++ tests/experimental/ops/test_catalog.py | 35 +++ tests/experimental/ops/test_plan.py | 94 ++++++++ 8 files changed, 607 insertions(+) create mode 100644 src/libtmux/experimental/engines/asyncio.py create mode 100644 src/libtmux/experimental/ops/catalog.py create mode 100644 src/libtmux/experimental/ops/plan.py create mode 100644 tests/experimental/contract/test_async_engine.py create mode 100644 tests/experimental/ops/test_catalog.py create mode 100644 tests/experimental/ops/test_plan.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 96f8068db..8b23b9b20 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -12,6 +12,7 @@ from __future__ import annotations +from libtmux.experimental.engines.asyncio import AsyncSubprocessEngine from libtmux.experimental.engines.base import ( AsyncTmuxEngine, CommandRequest, @@ -29,6 +30,7 @@ from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( + "AsyncSubprocessEngine", "AsyncTmuxEngine", "CommandRequest", "CommandResult", diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py new file mode 100644 index 000000000..3880efd36 --- /dev/null +++ b/src/libtmux/experimental/engines/asyncio.py @@ -0,0 +1,129 @@ +"""A real asynchronous subprocess engine. + +Built on :func:`asyncio.create_subprocess_exec` -- genuine async process I/O, +not a thread wrapper around the sync engine. On cancellation it terminates the +child process before propagating :class:`asyncio.CancelledError`, so a cancelled +``arun`` leaks no tmux process. It mirrors the classic engine's output handling +(``backslashreplace`` decoding, trailing-blank stripping, ``has-session`` fold) +so it returns the *same* typed result the classic engine does. +""" + +from __future__ import annotations + +import asyncio +import shutil +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class AsyncSubprocessEngine: + """Execute tmux commands via :func:`asyncio.create_subprocess_exec`. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SendKeys, arun + >>> from libtmux.experimental.ops._types import PaneId + >>> engine = AsyncSubprocessEngine() + >>> hasattr(engine, "run") and hasattr(engine, "run_batch") + True + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.server_args = tuple(server_args) + self._resolved_bin: str | None = None + + def _resolve_bin(self) -> str: + """Return the tmux binary path, memoized for the engine instance.""" + if self.tmux_bin is not None: + return self.tmux_bin + if self._resolved_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_bin = resolved + return self._resolved_bin + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command asynchronously and return its result.""" + tmux_bin = request.tmux_bin or self._resolve_bin() + cmd = [tmux_bin, *self.server_args, *request.args] + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + try: + stdout_bytes, stderr_bytes = await process.communicate() + except asyncio.CancelledError: + process.terminate() + await process.wait() + raise + + stdout = stdout_bytes.decode(errors="backslashreplace") + stderr = stderr_bytes.decode(errors="backslashreplace") + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = [line for line in stderr.split("\n") if line] + + if "has-session" in cmd and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=process.returncode if process.returncode is not None else -1, + ) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests sequentially (preserving tmux command ordering).""" + return [await self.run(req) for req in requests] + + @classmethod + def for_server(cls, server: t.Any) -> AsyncSubprocessEngine: + """Build an async engine bound to a live :class:`libtmux.Server`'s socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index c39f871d5..0e069dbd6 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -41,6 +41,7 @@ WindowId, render_target, ) +from libtmux.experimental.ops.catalog import CatalogEntry, catalog from libtmux.experimental.ops.exc import ( DuplicateOperation, OperationError, @@ -50,6 +51,7 @@ ) from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.plan import LazyPlan, PlanResult from libtmux.experimental.ops.registry import ( OperationRegistry, OpSpec, @@ -74,16 +76,19 @@ __all__ = ( "CapturePane", "CapturePaneResult", + "CatalogEntry", "ClientName", "DuplicateOperation", "Effects", "IndexRef", + "LazyPlan", "NameRef", "OpSpec", "Operation", "OperationError", "OperationRegistry", "PaneId", + "PlanResult", "Result", "Safety", "Scope", @@ -101,6 +106,7 @@ "VersionUnsupported", "WindowId", "arun", + "catalog", "operation_from_dict", "operation_to_dict", "register", diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py new file mode 100644 index 000000000..b0a272e67 --- /dev/null +++ b/src/libtmux/experimental/ops/catalog.py @@ -0,0 +1,91 @@ +"""A registry-driven operation catalog (the documentation data source). + +:func:`catalog` walks the operation registry and emits one structured +:class:`CatalogEntry` per operation -- scope, version gates, effects, safety, +result type, and a one-line summary. This is the data a Sphinx ``tmuxop`` domain +directive renders into the operation reference, so the registry is the single +source of truth for both runtime *and* docs and the two cannot drift apart. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.registry import registry as default_registry + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Safety, Scope + from libtmux.experimental.ops.registry import OperationRegistry + + +def _summary(doc: str | None) -> str: + """Return the first non-empty line of a docstring.""" + if not doc: + return "" + for line in doc.strip().splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +@dataclass(frozen=True) +class CatalogEntry: + """One operation's catalog record, derived from its registry spec.""" + + kind: str + command: str + scope: Scope + safety: Safety + primitive: bool + chainable: bool + result_type: str + min_version: str | None + flag_version_gates: dict[str, str] + effects: dict[str, t.Any] + summary: str + + +def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: + """Build catalog entries for every registered operation, sorted by kind. + + Parameters + ---------- + registry : OperationRegistry or None + The registry to read; defaults to the process-wide registry. + + Returns + ------- + list[CatalogEntry] + + Examples + -------- + >>> from libtmux.experimental.ops import catalog + >>> entries = catalog() + >>> [entry.kind for entry in entries] + ['capture_pane', 'select_layout', 'send_keys', 'split_window'] + >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") + >>> capture.scope, capture.safety, capture.result_type + ('pane', 'readonly', 'CapturePaneResult') + >>> capture.flag_version_gates["trim_trailing"] + '3.4' + """ + reg = registry if registry is not None else default_registry + return [ + CatalogEntry( + kind=spec.kind, + command=spec.command, + scope=spec.scope, + safety=spec.safety, + primitive=spec.primitive, + chainable=spec.chainable, + result_type=spec.result_cls.__name__, + min_version=spec.min_version, + flag_version_gates=dict(spec.flag_version_map), + effects=dataclasses.asdict(spec.effects), + summary=_summary(spec.operation_cls.__doc__), + ) + for spec in reg.list() + ] diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py new file mode 100644 index 000000000..50d07d10a --- /dev/null +++ b/src/libtmux/experimental/ops/plan.py @@ -0,0 +1,200 @@ +"""Lazy, deferred-resolution plans over the typed operation spine. + +A :class:`LazyPlan` records operations without touching tmux, so a plan can be +inspected, serialized, and executed later. Operations may target the *result of +an earlier operation* via a :class:`~._types.SlotRef` (e.g. send keys to the pane +a split is about to create); the plan resolves those references from captured ids +at execution time. + +Resolution is a sans-I/O generator -- the same yield-operation / resume-with- +result trampoline the chainable-commands prototype uses. The sync +:meth:`LazyPlan.execute` and async :meth:`LazyPlan.aexecute` drivers differ only +in ``run(...)`` versus ``await arun(...)``; the resolution logic is written once. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops._types import ( + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict + +if t.TYPE_CHECKING: + from collections.abc import Generator, Iterator + + from typing_extensions import Self + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +def _target_from_id(value: str) -> Target: + """Map a captured concrete id back to its typed target.""" + if value.startswith("%"): + return PaneId(value) + if value.startswith("@"): + return WindowId(value) + if value.startswith("$"): + return SessionId(value) + return Special(value) + + +def _resolve( + operation: Operation[t.Any], + bindings: dict[int, str], +) -> Operation[t.Any]: + """Substitute a :class:`SlotRef` target with a captured concrete id.""" + target = operation.target + if not isinstance(target, SlotRef): + return operation + try: + concrete = bindings[target.slot] + target.suffix + except KeyError as error: + msg = ( + f"slot {target.slot} has no captured id yet; a plan step can only " + f"target an earlier step that creates an object" + ) + raise OperationError(msg) from error + return dataclasses.replace(operation, target=_target_from_id(concrete)) + + +@dataclass(frozen=True) +class PlanResult: + """The outcome of executing a :class:`LazyPlan`. + + Parameters + ---------- + results : tuple[Result, ...] + One result per recorded operation, in order. + bindings : dict[int, str] + Maps a creating step's index to the concrete id it produced. + """ + + results: tuple[Result, ...] + bindings: dict[int, str] = field(default_factory=dict) + + @property + def ok(self) -> bool: + """Whether every step completed successfully.""" + return all(result.ok for result in self.results) + + def raise_for_status(self) -> Self: + """Raise on the first failed step; return ``self`` when all are OK.""" + for result in self.results: + result.raise_for_status() + return self + + +class LazyPlan: + """Record operations now; resolve refs and execute them later. + + Examples + -------- + Build a plan that splits a window then types into the *new* pane, and run it + against the in-memory concrete engine (no tmux required): + + >>> from libtmux.experimental.ops import 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="vim", enter=True)) + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.bindings + {0: '%1'} + >>> outcome.results[1].argv + ('send-keys', '-t', '%1', 'vim', 'Enter') + """ + + def __init__(self) -> None: + self._operations: list[Operation[t.Any]] = [] + + def add(self, operation: Operation[t.Any]) -> SlotRef: + """Record an operation; return a :class:`SlotRef` to its eventual id. + + The returned ref can be used as the ``target`` of a later operation to + address the object this one creates. + """ + self._operations.append(operation) + return SlotRef(len(self._operations) - 1) + + @property + def operations(self) -> tuple[Operation[t.Any], ...]: + """The recorded operations, in order.""" + return tuple(self._operations) + + def __len__(self) -> int: + """Return the number of recorded operations.""" + return len(self._operations) + + def __iter__(self) -> Iterator[Operation[t.Any]]: + """Iterate recorded operations in order.""" + return iter(self._operations) + + def to_list(self) -> list[dict[str, t.Any]]: + """Serialize the whole plan to a list of plain operation dicts.""" + return [operation_to_dict(operation) for operation in self._operations] + + @classmethod + def from_list(cls, data: t.Sequence[t.Mapping[str, t.Any]]) -> LazyPlan: + """Reconstruct a plan from :meth:`to_list` output.""" + plan = cls() + plan._operations = [operation_from_dict(item) for item in data] + return plan + + def _drive( + self, + version: str | None, + ) -> Generator[Operation[t.Any], Result, PlanResult]: + """Sans-I/O resolution core: yield a resolved op, resume with its result.""" + bindings: dict[int, str] = {} + results: list[Result] = [] + for index, operation in enumerate(self._operations): + result = yield _resolve(operation, bindings) + results.append(result) + created = getattr(result, "new_pane_id", None) + if created is not None: + bindings[index] = created + return PlanResult(tuple(results), bindings) + + def execute( + self, + engine: TmuxEngine, + *, + version: str | None = None, + ) -> PlanResult: + """Resolve and execute the plan synchronously.""" + gen = self._drive(version) + try: + operation = next(gen) + while True: + operation = gen.send(run(operation, engine, version=version)) + except StopIteration as stop: + return t.cast("PlanResult", stop.value) + + async def aexecute( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + ) -> PlanResult: + """Resolve and execute the plan asynchronously (same resolution core).""" + gen = self._drive(version) + try: + operation = next(gen) + while True: + operation = gen.send(await arun(operation, engine, version=version)) + except StopIteration as stop: + return t.cast("PlanResult", stop.value) diff --git a/tests/experimental/contract/test_async_engine.py b/tests/experimental/contract/test_async_engine.py new file mode 100644 index 000000000..1c37eb07e --- /dev/null +++ b/tests/experimental/contract/test_async_engine.py @@ -0,0 +1,50 @@ +"""Async engine against a real tmux server, and parity with the classic engine. + +Uses :func:`asyncio.run` to drive :func:`arun` so the async transport is +exercised end to end without a pytest-asyncio dependency. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import AsyncSubprocessEngine, SubprocessEngine +from libtmux.experimental.ops import SplitWindow, arun, run +from libtmux.experimental.ops._types import WindowId +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_async_split_creates_real_pane(session: Session) -> None: + """An async split returns a typed result whose new pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + engine = AsyncSubprocessEngine.for_server(server) + + result = asyncio.run(arun(SplitWindow(target=WindowId(window.window_id)), engine)) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_async_sync_parity(session: Session) -> None: + """The async and sync classic engines agree on result type and argv.""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + sync_result = run(operation, SubprocessEngine.for_server(server)) + async_result = asyncio.run( + arun(operation, AsyncSubprocessEngine.for_server(server)), + ) + + assert type(sync_result) is type(async_result) is SplitWindowResult + assert sync_result.argv == async_result.argv == operation.render() + assert sync_result.ok and async_result.ok diff --git a/tests/experimental/ops/test_catalog.py b/tests/experimental/ops/test_catalog.py new file mode 100644 index 000000000..a62e3ce34 --- /dev/null +++ b/tests/experimental/ops/test_catalog.py @@ -0,0 +1,35 @@ +"""Tests for the registry-driven operation catalog.""" + +from __future__ import annotations + +from libtmux.experimental.ops import catalog, registry + + +def test_catalog_covers_every_registered_operation() -> None: + """The catalog has exactly one entry per registered kind.""" + entries = catalog() + assert [entry.kind for entry in entries] == sorted(registry.kinds()) + + +def test_catalog_entry_mirrors_spec() -> None: + """A catalog entry reflects the operation's registry metadata.""" + entries = {entry.kind: entry for entry in catalog()} + + split = entries["split_window"] + assert split.command == "split-window" + assert split.scope == "window" + assert split.result_type == "SplitWindowResult" + assert split.effects["creates"] == "pane" + assert split.flag_version_gates == {"environment": "3.0"} + assert split.summary + + capture = entries["capture_pane"] + assert capture.safety == "readonly" + assert capture.effects["read_only"] is True + assert capture.flag_version_gates["trim_trailing"] == "3.4" + + +def test_catalog_summary_is_first_docstring_line() -> None: + """Each entry's summary is the operation's one-line description.""" + entries = {entry.kind: entry for entry in catalog()} + assert entries["send_keys"].summary.startswith("Send keys") diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py new file mode 100644 index 000000000..bbba688e7 --- /dev/null +++ b/tests/experimental/ops/test_plan.py @@ -0,0 +1,94 @@ +"""Tests for the lazy plan and deferred-ref resolution.""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + LazyPlan, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + + +class _AsyncConcreteEngine: + """Async wrapper over :class:`ConcreteEngine` for plan.aexecute tests.""" + + def __init__(self) -> None: + self._inner = ConcreteEngine() + + async def run(self, request: CommandRequest) -> CommandResult: + """Run synchronously under the hood; awaitable for the async driver.""" + return self._inner.run(request) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute each request in order.""" + return [await self.run(req) for req in requests] + + +def test_plan_records_without_executing() -> None: + """Building a plan touches no engine; it just records operations.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=PaneId("%1"), keys="x")) + assert len(plan) == 2 + assert [op.kind for op in plan] == ["split_window", "send_keys"] + + +def test_plan_resolves_forward_ref() -> None: + """A later step can target the pane an earlier split creates.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + outcome = plan.execute(ConcreteEngine()) + + assert outcome.bindings == {0: "%1"} + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + assert outcome.ok + + +def test_plan_aexecute_matches_execute() -> None: + """The async driver resolves refs identically to the sync driver.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + outcome = asyncio.run(plan.aexecute(_AsyncConcreteEngine())) + + assert outcome.bindings == {0: "%1"} + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + + +def test_plan_serialization_round_trip() -> None: + """A plan (including its SlotRef targets) survives a list round-trip.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="x")) + + revived = LazyPlan.from_list(plan.to_list()) + + assert revived.operations == plan.operations + + +def test_plan_unresolvable_ref_fails_closed() -> None: + """Targeting a step that creates nothing raises a clear error.""" + plan = LazyPlan() + typed = plan.add(SendKeys(target=PaneId("%1"), keys="x")) # creates no id + plan.add(SendKeys(target=typed, keys="y")) + with pytest.raises(OperationError, match="no captured id"): + plan.execute(ConcreteEngine()) From 040e261c89d1de67eb5d9d6e451573f32bf68a0e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:18:16 -0500 Subject: [PATCH 004/223] Ops(feat): Add persistent control-mode engine why: Proves control mode is just another engine returning the same typed result (issue 689 phase 4) -- an operation run over a persistent tmux -C connection is indistinguishable, at the result level, from one run via fork-per-call subprocess. what: - engines.control_mode: ControlModeEngine over one persistent tmux -C connection; run_batch pipelines commands and parses each command's %begin/%end/%error block into a CommandResult; selectors-based nonblocking reads with timeout; startup-ACK discard; lifecycle via close()/context manager (lock-guarded teardown) - engines.control_mode: I/O-free ControlModeParser, unit-testable without tmux, adapted from the chain runner + protocol-engines parser - register control_mode in the engine registry and export it - tests: pure parser tests + real-tmux contract (split creates a real pane, batched commands, control-vs-concrete parity) --- src/libtmux/experimental/engines/__init__.py | 8 + .../experimental/engines/control_mode.py | 417 ++++++++++++++++++ src/libtmux/experimental/engines/registry.py | 2 + .../contract/test_control_engine.py | 77 ++++ tests/experimental/ops/test_control_parser.py | 49 ++ 5 files changed, 553 insertions(+) create mode 100644 src/libtmux/experimental/engines/control_mode.py create mode 100644 tests/experimental/contract/test_control_engine.py create mode 100644 tests/experimental/ops/test_control_parser.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 8b23b9b20..a8528c9d9 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -22,6 +22,11 @@ TmuxEngine, ) from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.control_mode import ( + ControlModeEngine, + ControlModeError, + ControlModeParser, +) from libtmux.experimental.engines.registry import ( available_engines, create_engine, @@ -35,6 +40,9 @@ "CommandRequest", "CommandResult", "ConcreteEngine", + "ControlModeEngine", + "ControlModeError", + "ControlModeParser", "EngineKind", "EngineSpec", "SubprocessEngine", diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py new file mode 100644 index 000000000..ae113b672 --- /dev/null +++ b/src/libtmux/experimental/engines/control_mode.py @@ -0,0 +1,417 @@ +"""A persistent control-mode (``tmux -C``) engine. + +Holds one long-lived ``tmux -C`` connection and pipelines command lines over it, +parsing each command's ``%begin``/``%end``/``%error`` block back into a +:class:`~.base.CommandResult`. Because it returns the same typed result the +subprocess engine does, an operation run through control mode is +indistinguishable -- at the result level -- from one run through a fork-per-call +subprocess. Adapted from the chainable-commands control runner and the +``libtmux-protocol-engines`` parser. + +The parser (:class:`ControlModeParser`) is I/O-free: it consumes bytes and emits +parsed blocks, so it is unit-testable without spawning tmux. ``run_batch`` writes +all command lines at once and collects one block per command, which is the +control engine's advantage over per-call subprocess startup. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import logging +import os +import selectors +import shlex +import shutil +import subprocess +import threading +import time +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + +logger = logging.getLogger(__name__) + +_BEGIN_PREFIX = b"%begin " +_END_PREFIX = b"%end " +_ERROR_PREFIX = b"%error " +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_GRACEFUL_EXIT_TIMEOUT = 0.5 +_TERMINATE_TIMEOUT = 1.0 +_GUARD_MIN_PARTS = 3 + + +class ControlModeError(exc.LibTmuxException): + """The control-mode engine failed (connection, protocol, or timeout).""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ControlModeBlock: + """One ``%begin``/``%end`` or ``%error`` control-mode command block.""" + + number: int + flags: int + is_error: bool + body: tuple[bytes, ...] + + +@dataclasses.dataclass(slots=True) +class _PendingBlock: + number: int + flags: int + body: list[bytes] + + +class ControlModeParser: + r"""I/O-free parser for the command-block subset of control mode. + + Examples + -------- + >>> parser = ControlModeParser() + >>> parser.feed(b"%begin 1 1 1\nhello\n%end 1 1 1\n") + >>> [block.body for block in parser.blocks()] + [(b'hello',)] + >>> parser.feed(b"%begin 2 2 1\nboom\n%error 2 2 1\n") + >>> block = parser.blocks()[0] + >>> block.is_error, block.body + (True, (b'boom',)) + """ + + __slots__ = ("_blocks", "_buffer", "_pending") + + def __init__(self) -> None: + self._buffer = bytearray() + self._blocks: list[ControlModeBlock] = [] + self._pending: _PendingBlock | None = None + + def feed(self, data: bytes) -> None: + """Consume bytes from tmux stdout.""" + if not data: + return + self._buffer.extend(data) + while True: + newline = self._buffer.find(b"\n") + if newline < 0: + return + line = bytes(self._buffer[:newline]) + del self._buffer[: newline + 1] + self._handle_line(line) + + def blocks(self) -> list[ControlModeBlock]: + """Drain parsed command blocks.""" + blocks, self._blocks = self._blocks, [] + return blocks + + def _handle_line(self, line: bytes) -> None: + if self._pending is not None: + if _matches_pending_close(line, self._pending.number): + self._close_block(line) + return + self._pending.body.append(line) + return + if line.startswith(_BEGIN_PREFIX): + self._open_block(line) + + def _open_block(self, line: bytes) -> None: + number, flags = _parse_guard(line, _BEGIN_PREFIX) + if number is None: + return + self._pending = _PendingBlock(number=number, flags=flags or 0, body=[]) + + def _close_block(self, line: bytes) -> None: + pending = self._pending + self._pending = None + if pending is None: + return + self._blocks.append( + ControlModeBlock( + number=pending.number, + flags=pending.flags, + is_error=line.startswith(_ERROR_PREFIX), + body=tuple(pending.body), + ), + ) + + +class ControlModeEngine: + """Execute tmux commands over one persistent ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to wait for a batch of result blocks before raising. + + Notes + ----- + The connection is opened lazily on first use. Call :meth:`close` (or use the + engine as a context manager) to tear it down. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self.tmux_bin = tmux_bin + self.server_args = tuple(server_args) + self.timeout = timeout + self._lock = threading.Lock() + self._parser = ControlModeParser() + self._proc: subprocess.Popen[bytes] | None = None + self._selector: selectors.DefaultSelector | None = None + self._startup_ack_pending = True + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command over the control connection.""" + return self.run_batch([request])[0] + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Pipeline a batch of commands; one result block per request.""" + if not requests: + return [] + rendered = [tuple(req.args) for req in requests] + with self._lock: + self._ensure_started() + payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + self._write(payload) + blocks = self._read_blocks(len(rendered)) + return [ + _result_from_block(block, argv) + for block, argv in zip(blocks, rendered, strict=True) + ] + + def close(self) -> None: + """Tear down the control-mode subprocess (lock-guarded).""" + with self._lock: + proc = self._proc + selector = self._selector + self._proc = None + self._selector = None + self._parser = ControlModeParser() + self._startup_ack_pending = True + if selector is not None: + with contextlib.suppress(Exception): + selector.close() + if proc is None: + return + if proc.stdin is not None and not proc.stdin.closed: + with contextlib.suppress(OSError): + proc.stdin.close() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + with contextlib.suppress(OSError): + proc.terminate() + if not _wait_for_exit(proc, _TERMINATE_TIMEOUT): + with contextlib.suppress(OSError): + proc.kill() + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=_TERMINATE_TIMEOUT) + + def __enter__(self) -> ControlModeEngine: + """Return this engine.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Tear down the connection on context exit.""" + self.close() + + def _ensure_started(self) -> None: + if self._proc is not None: + if self._proc.poll() is not None: + msg = f"tmux -C exited with code {self._proc.returncode}" + raise ControlModeError(msg) + return + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + if proc.stdin is None or proc.stdout is None or proc.stderr is None: + with contextlib.suppress(OSError): + proc.kill() + msg = "tmux -C subprocess pipes are unavailable" + raise ControlModeError(msg) + os.set_blocking(proc.stdout.fileno(), False) + os.set_blocking(proc.stderr.fileno(), False) + selector = selectors.DefaultSelector() + selector.register(proc.stdout, selectors.EVENT_READ, "stdout") + selector.register(proc.stderr, selectors.EVENT_READ, "stderr") + self._proc = proc + self._selector = selector + self._startup_ack_pending = True + + def _write(self, payload: bytes) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + try: + proc.stdin.write(payload) + proc.stdin.flush() + except (BrokenPipeError, OSError) as error: + msg = f"tmux control-mode write failed: {error}" + raise ControlModeError(msg) from error + + def _read_blocks(self, count: int) -> list[ControlModeBlock]: + proc = self._proc + selector = self._selector + if proc is None or selector is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + blocks: list[ControlModeBlock] = [] + deadline = time.monotonic() + self.timeout + while len(blocks) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + msg = ( + f"tmux control-mode timed out after {self.timeout}s " + f"waiting for {count} result blocks" + ) + raise ControlModeError(msg) + ready = selector.select(min(remaining, 0.1)) + if not ready: + if proc.poll() is not None: + msg = f"tmux -C exited with code {proc.returncode}" + raise ControlModeError(msg) + continue + for key, _events in ready: + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() + for block in self._parser.blocks(): + if self._startup_ack_pending: + self._startup_ack_pending = False + if block.flags == 0: + continue + blocks.append(block) + if len(blocks) == count: + break + return blocks + + def _read_stdout(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + chunk = os.read(proc.stdout.fileno(), _READ_CHUNK) + except BlockingIOError: + return + except OSError as error: + msg = f"tmux control-mode stdout read failed: {error}" + raise ControlModeError(msg) from error + if not chunk: + msg = "tmux -C closed stdout" + raise ControlModeError(msg) + self._parser.feed(chunk) + + def _read_stderr(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + try: + chunk = os.read(proc.stderr.fileno(), _READ_CHUNK) + except (BlockingIOError, OSError): + return + if chunk: + logger.debug( + "tmux control-mode stderr", + extra={"tmux_stderr": [chunk.decode(errors="replace")]}, + ) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> ControlModeEngine: + """Build a control-mode engine bound to a live server's socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls( + tmux_bin=getattr(server, "tmux_bin", None), + server_args=server_args, + **kwargs, + ) + + +def _wait_for_exit(proc: subprocess.Popen[bytes], timeout: float) -> bool: + """Wait up to *timeout* for the process to exit; return whether it did.""" + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return False + return True + + +def _parse_guard(line: bytes, prefix: bytes) -> tuple[int | None, int | None]: + """Parse a ``%begin``/``%end``/``%error`` guard's number and flags.""" + parts = line[len(prefix) :].split() + if len(parts) < _GUARD_MIN_PARTS: + return (None, None) + try: + return (int(parts[1]), int(parts[2])) + except ValueError: + return (None, None) + + +def _matches_pending_close(line: bytes, pending_number: int) -> bool: + """Whether *line* closes the pending block numbered *pending_number*.""" + for prefix in (_END_PREFIX, _ERROR_PREFIX): + if line.startswith(prefix): + number, _flags = _parse_guard(line, prefix) + return number == pending_number + return False + + +def _result_from_block( + block: ControlModeBlock, + argv: tuple[str, ...], +) -> CommandResult: + """Convert a parsed control-mode block into a :class:`CommandResult`.""" + lines = tuple(line.decode(errors="replace") for line in block.body) + cmd = ("tmux", "-C", *argv) + if block.is_error: + return CommandResult(cmd=cmd, stdout=(), stderr=_trim(lines), returncode=1) + return CommandResult(cmd=cmd, stdout=_trim(lines), stderr=(), returncode=0) + + +def _trim(lines: tuple[str, ...]) -> tuple[str, ...]: + """Drop trailing blank lines.""" + trimmed = list(lines) + while trimmed and not trimmed[-1].strip(): + trimmed.pop() + return tuple(trimmed) diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py index 0a7fb10d5..2809eb356 100644 --- a/src/libtmux/experimental/engines/registry.py +++ b/src/libtmux/experimental/engines/registry.py @@ -13,6 +13,7 @@ from libtmux import exc from libtmux.experimental.engines.base import EngineKind from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.control_mode import ControlModeEngine from libtmux.experimental.engines.subprocess import SubprocessEngine if t.TYPE_CHECKING: @@ -66,3 +67,4 @@ def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: register_engine(EngineKind.SUBPROCESS.value, SubprocessEngine) register_engine(EngineKind.CONCRETE.value, ConcreteEngine) +register_engine(EngineKind.CONTROL_MODE.value, ControlModeEngine) diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py new file mode 100644 index 000000000..07966326e --- /dev/null +++ b/tests/experimental/contract/test_control_engine.py @@ -0,0 +1,77 @@ +"""Control-mode engine against a real tmux server, and parity with concrete. + +Exercises the persistent ``tmux -C`` engine end to end and asserts it returns +the same typed result shape the other engines do. The engine is used as a +context manager so the control connection is always torn down. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, ControlModeEngine +from libtmux.experimental.ops import ( + CapturePane, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.results import ( + CapturePaneResult, + Result, + SplitWindowResult, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_control_split_creates_real_pane(session: Session) -> None: + """A control-mode split returns a typed result whose pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + + with ControlModeEngine.for_server(server) as engine: + result = run(SplitWindow(target=WindowId(window.window_id)), engine) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_control_batches_multiple_commands(session: Session) -> None: + """run_batch pipelines several commands over one connection, one result each.""" + server = session.server + pane = session.active_pane + window = session.active_window + assert pane is not None + assert pane.pane_id is not None + assert window.window_id is not None + + with ControlModeEngine.for_server(server) as engine: + sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) + captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert type(sent) is Result + assert sent.ok + assert isinstance(captured, CapturePaneResult) + assert captured.ok + + +def test_control_concrete_parity(session: Session) -> None: + """Control-mode and concrete engines agree on result type and argv.""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + with ControlModeEngine.for_server(server) as engine: + control = run(operation, engine) + concrete = run(operation, ConcreteEngine()) + + assert type(control) is type(concrete) is SplitWindowResult + assert control.argv == concrete.argv == operation.render() + assert control.ok and concrete.ok diff --git a/tests/experimental/ops/test_control_parser.py b/tests/experimental/ops/test_control_parser.py new file mode 100644 index 000000000..fc29bb9a4 --- /dev/null +++ b/tests/experimental/ops/test_control_parser.py @@ -0,0 +1,49 @@ +"""Pure (no-tmux) tests for the control-mode block parser.""" + +from __future__ import annotations + +from libtmux.experimental.engines import ControlModeParser + + +def test_parses_success_block() -> None: + """A ``%begin``/``%end`` pair yields one non-error block with its body.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nhello\nworld\n%end 1 1 1\n") + blocks = parser.blocks() + assert len(blocks) == 1 + assert not blocks[0].is_error + assert blocks[0].body == (b"hello", b"world") + + +def test_parses_error_block() -> None: + """A ``%error`` close marks the block as an error.""" + parser = ControlModeParser() + parser.feed(b"%begin 2 5 1\ncan't find pane\n%error 2 5 1\n") + block = parser.blocks()[0] + assert block.is_error + assert block.body == (b"can't find pane",) + + +def test_handles_split_chunks() -> None: + """Bytes split mid-line across feeds still parse into one block.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nhel") + parser.feed(b"lo\n%end 1 1 1\n") + assert parser.blocks()[0].body == (b"hello",) + + +def test_blocks_drains() -> None: + """``blocks`` returns parsed blocks once, then is empty.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nx\n%end 1 1 1\n") + assert len(parser.blocks()) == 1 + assert parser.blocks() == [] + + +def test_ignores_noise_outside_blocks() -> None: + """Notification lines outside a block are ignored by the command parser.""" + parser = ControlModeParser() + parser.feed(b"%output %1 hi\n%begin 1 1 1\nok\n%end 1 1 1\n") + blocks = parser.blocks() + assert len(blocks) == 1 + assert blocks[0].body == (b"ok",) From 7e7df41140229f954034c76199625c68092bed90 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:23:02 -0500 Subject: [PATCH 005/223] Ops(feat): Add eager + lazy pane facades over the spine why: Demonstrates the "mode lives in the type" model from issue 689 -- EagerPane.split() returns a live EagerPane while LazyPane.split() returns a deferred LazyPane, each a single statically-known return type, both backed by the same SplitWindow operation. One Pane class with a runtime-bound engine could not type these return values distinctly. what: - facade.pane.EagerPane: executes immediately, returns live handles (split -> EagerPane), typed results for capture/send_keys - facade.pane.LazyPane: records into a LazyPlan, returns deferred handles (split -> LazyPane bound to the new pane's SlotRef), chainable - seed of the wider Server/Session/Window/Pane/Client x mode matrix - tests: eager live handles, lazy deferral + forward-ref resolution, and same-operation-backs-both-facades parity --- src/libtmux/experimental/facade/__init__.py | 17 ++ src/libtmux/experimental/facade/pane.py | 165 ++++++++++++++++++ tests/experimental/facade/__init__.py | 3 + tests/experimental/facade/test_pane_facade.py | 62 +++++++ 4 files changed, 247 insertions(+) create mode 100644 src/libtmux/experimental/facade/__init__.py create mode 100644 src/libtmux/experimental/facade/pane.py create mode 100644 tests/experimental/facade/__init__.py create mode 100644 tests/experimental/facade/test_pane_facade.py diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/facade/__init__.py new file mode 100644 index 000000000..965b8f4da --- /dev/null +++ b/src/libtmux/experimental/facade/__init__.py @@ -0,0 +1,17 @@ +"""Engine-typed facades over the operation spine. + +The execution mode lives in the facade *type* (eager vs lazy vs async vs +control), so each method has one statically-known return type, while the +operation definitions stay shared. This package currently ships the pane-scope +seed (:class:`EagerPane`, :class:`LazyPane`); the full Server/Session/Window/ +Pane/Client matrix is described in issue 689. +""" + +from __future__ import annotations + +from libtmux.experimental.facade.pane import EagerPane, LazyPane + +__all__ = ( + "EagerPane", + "LazyPane", +) diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/facade/pane.py new file mode 100644 index 000000000..47e803791 --- /dev/null +++ b/src/libtmux/experimental/facade/pane.py @@ -0,0 +1,165 @@ +"""Pane-scope facades demonstrating "mode lives in the type". + +Two thin facades over the *same* operation spine show why the execution mode +belongs in the class rather than a runtime flag: + +- :class:`EagerPane` executes immediately and returns *live* handles + (``split()`` -> :class:`EagerPane`), so its return types are concrete. +- :class:`LazyPane` records into a :class:`~libtmux.experimental.ops.plan.LazyPlan` + and returns *deferred* handles (``split()`` -> :class:`LazyPane`), executing + only when the plan runs. + +Each ``split()`` therefore has exactly one statically-known return type -- a +single ``Pane`` class with a runtime engine attribute could not express that. +The same :class:`~libtmux.experimental.ops.SplitWindow` operation backs both; +only the facade differs. This is the seed of the wider facade matrix +(``AsyncPane``, ``LazyControlWindow``, ...) described in issue 689. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops import ( + CapturePane, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import CapturePaneResult, Result + + +@dataclass(frozen=True) +class EagerPane: + """A live pane handle bound to an engine; methods execute immediately. + + Parameters + ---------- + engine : TmuxEngine + The engine commands run through. + pane_id : str + The concrete tmux pane id (``%N``). + version : str or None + tmux version to render against. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> pane = EagerPane(ConcreteEngine(), "%0") + >>> child = pane.split(horizontal=True) + >>> child.pane_id + '%1' + >>> isinstance(pane.capture().lines, tuple) + True + """ + + engine: TmuxEngine + pane_id: str + version: str | None = None + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> EagerPane: + """Split this pane and return a live handle to the new pane.""" + result = run( + SplitWindow( + target=PaneId(self.pane_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + + def send_keys(self, keys: str, *, enter: bool = False) -> Result: + """Send keys to this pane; return the typed result.""" + return run( + SendKeys(target=PaneId(self.pane_id), keys=keys, enter=enter), + self.engine, + version=self.version, + ) + + def capture( + self, *, start: int | None = None, end: int | None = None + ) -> CapturePaneResult: + """Capture this pane's contents; return the typed result.""" + return run( + CapturePane(target=PaneId(self.pane_id), start=start, end=end), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyPane: + """A deferred pane handle; methods record into a plan instead of running. + + Parameters + ---------- + plan : LazyPlan + The plan operations are recorded into. + ref : Target + The target this handle addresses (a concrete id, or a SlotRef for a + pane created earlier in the plan). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import PaneId + >>> plan = LazyPlan() + >>> root = LazyPane(plan, PaneId("%0")) + >>> child = root.split() + >>> _ = child.send_keys("vim", enter=True) + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.results[0].new_pane_id + '%1' + >>> outcome.results[1].argv + ('send-keys', '-t', '%1', 'vim', 'Enter') + """ + + plan: LazyPlan + ref: Target + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> LazyPane: + """Record a split; return a deferred handle to the pane it will create.""" + slot = self.plan.add( + SplitWindow( + target=self.ref, + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + ) + return LazyPane(self.plan, slot) + + def send_keys(self, keys: str, *, enter: bool = False) -> LazyPane: + """Record a send-keys against this handle; return self for chaining.""" + self.plan.add(SendKeys(target=self.ref, keys=keys, enter=enter)) + return self + + def capture(self, *, start: int | None = None, end: int | None = None) -> LazyPane: + """Record a capture against this handle; return self for chaining.""" + self.plan.add(CapturePane(target=self.ref, start=start, end=end)) + return self diff --git a/tests/experimental/facade/__init__.py b/tests/experimental/facade/__init__.py new file mode 100644 index 000000000..a260ebd4d --- /dev/null +++ b/tests/experimental/facade/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.facade.""" + +from __future__ import annotations diff --git a/tests/experimental/facade/test_pane_facade.py b/tests/experimental/facade/test_pane_facade.py new file mode 100644 index 000000000..d5508691a --- /dev/null +++ b/tests/experimental/facade/test_pane_facade.py @@ -0,0 +1,62 @@ +"""Tests for the eager and lazy pane facades.""" + +from __future__ import annotations + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.facade import EagerPane, LazyPane +from libtmux.experimental.ops import LazyPlan +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.results import SplitWindowResult + + +def test_eager_split_returns_live_pane() -> None: + """EagerPane.split executes now and returns a live EagerPane handle.""" + pane = EagerPane(ConcreteEngine(), "%0") + child = pane.split(horizontal=True) + assert isinstance(child, EagerPane) + assert child.pane_id == "%1" + + +def test_eager_capture_and_send() -> None: + """Eager capture/send-keys return typed results.""" + engine = ConcreteEngine(capture_lines=("a", "b")) + pane = EagerPane(engine, "%1") + assert pane.capture().lines == ("a", "b") + assert pane.send_keys("echo hi", enter=True).ok + + +def test_lazy_split_returns_deferred_handle_and_defers() -> None: + """LazyPane.split records into a plan and returns a deferred LazyPane.""" + plan = LazyPlan() + root = LazyPane(plan, PaneId("%0")) + child = root.split() + assert isinstance(child, LazyPane) + assert len(plan) == 1 # recorded, not executed + + +def test_lazy_chain_resolves_forward_ref_on_execute() -> None: + """A lazy chain resolves the new pane's id when the plan runs.""" + plan = LazyPlan() + root = LazyPane(plan, PaneId("%0")) + root.split().send_keys("vim", enter=True) + + outcome = plan.execute(ConcreteEngine()) + + first = outcome.results[0] + assert isinstance(first, SplitWindowResult) + assert first.new_pane_id == "%1" + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + + +def test_same_operation_backs_both_facades() -> None: + """Eager and lazy facades render the identical underlying operation argv.""" + eager_engine = ConcreteEngine() + eager = EagerPane(eager_engine, "%0") + # Capture the eager split's rendered argv via the engine-independent op. + plan = LazyPlan() + LazyPane(plan, PaneId("%0")).split(horizontal=True) + lazy_argv = plan.operations[0].render() + + eager_child = eager.split(horizontal=True) + assert eager_child.pane_id # executed + assert lazy_argv == ("split-window", "-t", "%0", "-h", "-P", "-F", "#{pane_id}") From 9248294f5e4b700d635143f410227a8ee6043404 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:57:58 -0500 Subject: [PATCH 006/223] Ops(feat): Add async control-mode + concrete engines why: Closes the two async gaps from issue 689: control mode and concrete had no async sibling. The async control engine is the one async engine that earns its place -- it adds an event stream subprocess cannot -- and prior libtmux/mux control-mode work (surfaced across agent histories via agentgrep, plus the asyncio-2 branches) shaped its correlation design. what: - engines.async_control_mode: AsyncControlModeEngine over a persistent tmux -C (create_subprocess_exec + one reader task). FIFO future correlation with skip-when-empty so unsolicited %begin blocks (hook- triggered commands and the startup ACK) never desync results; the startup ACK is consumed synchronously in start() to close the correlation race our whole-block parser would otherwise have. DEAD state fails pending commands on reader EOF/error. Cancellation via asyncio.wait_for (3.10 floor: no asyncio.timeout/TaskGroup). Bounded subscribe() notification stream with drop-counting. for_server() helper - engines.control_mode: ControlModeParser now surfaces bare %-notification lines via notifications() (additive; the sync engine ignores them) - engines.concrete: AsyncConcreteEngine sibling over shared simulation; removes the async test shim - ControlNotification typed event value - tests: parser notification/drain; async control vs real tmux (split, pipelined batch, concrete parity, live event stream, lifecycle) --- src/libtmux/experimental/engines/__init__.py | 9 +- .../engines/async_control_mode.py | 358 ++++++++++++++++++ src/libtmux/experimental/engines/concrete.py | 103 +++-- .../experimental/engines/control_mode.py | 16 +- .../contract/test_async_control_engine.py | 98 +++++ tests/experimental/ops/test_control_parser.py | 19 +- tests/experimental/ops/test_plan.py | 28 +- 7 files changed, 571 insertions(+), 60 deletions(-) create mode 100644 src/libtmux/experimental/engines/async_control_mode.py create mode 100644 tests/experimental/contract/test_async_control_engine.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index a8528c9d9..6738d4716 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -12,6 +12,10 @@ from __future__ import annotations +from libtmux.experimental.engines.async_control_mode import ( + AsyncControlModeEngine, + ControlNotification, +) from libtmux.experimental.engines.asyncio import AsyncSubprocessEngine from libtmux.experimental.engines.base import ( AsyncTmuxEngine, @@ -21,7 +25,7 @@ EngineSpec, TmuxEngine, ) -from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.concrete import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.engines.control_mode import ( ControlModeEngine, ControlModeError, @@ -35,6 +39,8 @@ from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( + "AsyncConcreteEngine", + "AsyncControlModeEngine", "AsyncSubprocessEngine", "AsyncTmuxEngine", "CommandRequest", @@ -43,6 +49,7 @@ "ControlModeEngine", "ControlModeError", "ControlModeParser", + "ControlNotification", "EngineKind", "EngineSpec", "SubprocessEngine", diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py new file mode 100644 index 000000000..ccfaafdbe --- /dev/null +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -0,0 +1,358 @@ +"""An asynchronous control-mode (``tmux -C``) engine with an event stream. + +A real async control engine -- not an ``asyncio.to_thread`` wrapper around the +sync one. It holds a persistent ``tmux -C`` connection, reads it from a single +background task, correlates each command to an :class:`asyncio.Future`, and +exposes tmux's asynchronous notifications (``%output``, ``%window-add``, ...) as +an ``async for`` event stream. + +Design, informed by prior libtmux/mux control-mode work: + +- The I/O-free :class:`~.control_mode.ControlModeParser` is reused verbatim; only + the I/O layer differs from the sync engine (``await stdout.read`` instead of + ``selectors``). +- Command correlation is a FIFO of futures resolved in block-arrival order. A + block that arrives with *no* pending command is **unsolicited** (a hook- + triggered command, or the startup ACK) and is skipped, so correlation never + desyncs. The startup ACK is consumed synchronously in :meth:`start` before the + reader launches, closing the startup race. +- A reader failure or EOF marks the engine *dead* and fails every pending + command, rather than hanging. +- Notifications go to a bounded queue; on overflow the oldest is dropped and + counted (backpressure), mirroring control mode's own ``%pause`` philosophy. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import shlex +import shutil +import typing as t +from dataclasses import dataclass + +from libtmux import exc +from libtmux.experimental.engines.control_mode import ( + ControlModeError, + ControlModeParser, + _result_from_block, +) + +if t.TYPE_CHECKING: + import types + from collections.abc import AsyncIterator, Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 +_STOP_TIMEOUT = 2.0 + + +@dataclass(frozen=True) +class ControlNotification: + """An asynchronous tmux control-mode notification. + + Examples + -------- + >>> ControlNotification.parse(b"%window-add @3") + ControlNotification(kind='window-add', args=('@3',), raw='%window-add @3') + >>> ControlNotification.parse(b"%output %1 hello world").kind + 'output' + """ + + kind: str + args: tuple[str, ...] + raw: str + + @classmethod + def parse(cls, line: bytes) -> ControlNotification: + """Parse a raw ``%``-notification line.""" + text = line.decode(errors="replace") + body = text[1:] if text.startswith("%") else text + parts = body.split(" ") + kind = parts[0] if parts else "" + return cls(kind=kind, args=tuple(parts[1:]), raw=text) + + +@dataclass(slots=True) +class _PendingCommand: + future: asyncio.Future[CommandResult] + argv: tuple[str, ...] + + +class AsyncControlModeEngine: + """Execute tmux commands over one persistent async ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to await a command's result before failing it. + event_queue_size : int + Bounded size of the notification queue (backpressure). + + Notes + ----- + The connection opens lazily on first use. Use the engine as an async context + manager, or call :meth:`aclose`, to tear it down. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + event_queue_size: int = 4096, + ) -> None: + self.tmux_bin = tmux_bin + self.server_args = tuple(server_args) + self.timeout = timeout + self._parser = ControlModeParser() + self._pending: collections.deque[_PendingCommand] = collections.deque() + self._events: asyncio.Queue[ControlNotification] = asyncio.Queue( + maxsize=event_queue_size, + ) + self._dropped_notifications = 0 + self._proc: asyncio.subprocess.Process | None = None + self._reader_task: asyncio.Task[None] | None = None + self._start_lock = asyncio.Lock() + self._write_lock = asyncio.Lock() + self._started = False + self._dead: BaseException | None = None + + async def start(self) -> None: + """Spawn ``tmux -C``, consume the startup ACK, and start the reader.""" + async with self._start_lock: + if self._started: + return + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + self._proc = proc + self._dead = None + await self._consume_startup() + self._reader_task = asyncio.create_task( + self._reader(), + name="libtmux-async-control-reader", + ) + self._started = True + + async def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before commands flow. + + Doing this synchronously (before the reader task launches and before any + command future is queued) means the startup block can never be matched + to a real command. + """ + proc = self._proc + if proc is None or proc.stdout is None: + return + loop = asyncio.get_running_loop() + deadline = loop.time() + _STARTUP_TIMEOUT + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return + try: + chunk = await asyncio.wait_for( + proc.stdout.read(_READ_CHUNK), + timeout=remaining, + ) + except asyncio.TimeoutError: + return + if not chunk: + return + self._parser.feed(chunk) + self._parser.notifications() # discard any startup notifications + if self._parser.blocks(): # startup ACK seen and discarded + return + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command over the control connection.""" + return (await self.run_batch([request]))[0] + + async def run_batch( + self, requests: Sequence[CommandRequest] + ) -> list[CommandResult]: + """Pipeline a batch of commands; one result per request, in order.""" + if not requests: + return [] + await self.start() + if self._dead is not None: + msg = "control-mode engine is dead" + raise ControlModeError(msg) from self._dead + + loop = asyncio.get_running_loop() + rendered = [tuple(req.args) for req in requests] + futures: list[asyncio.Future[CommandResult]] = [] + async with self._write_lock: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + for argv in rendered: + future: asyncio.Future[CommandResult] = loop.create_future() + self._pending.append(_PendingCommand(future, argv)) + futures.append(future) + payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + try: + proc.stdin.write(payload) + await proc.stdin.drain() + except (BrokenPipeError, OSError) as error: + msg = f"tmux control-mode write failed: {error}" + raise ControlModeError(msg) from error + + try: + return await asyncio.wait_for( + asyncio.gather(*futures), + timeout=self.timeout, + ) + except asyncio.TimeoutError as error: + # The futures stay queued (now cancelled); the reader drains their + # blocks on arrival, keeping FIFO correlation aligned. + msg = f"tmux control-mode timed out after {self.timeout}s" + raise ControlModeError(msg) from error + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield asynchronous tmux notifications as they arrive. + + The iterator runs until the engine is closed or cancelled by the caller. + """ + while True: + yield await self._events.get() + + @property + def dropped_notifications(self) -> int: + """How many notifications were dropped due to a full event queue.""" + return self._dropped_notifications + + async def aclose(self) -> None: + """Tear down the connection: cancel the reader, fail pending, kill proc.""" + if not self._started: + return + self._started = False + reader = self._reader_task + self._reader_task = None + if reader is not None: + reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reader + self._fail_pending(ControlModeError("control-mode engine closed")) + proc = self._proc + self._proc = None + if proc is not None and proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=_STOP_TIMEOUT) + except asyncio.TimeoutError: + with contextlib.suppress(ProcessLookupError): + proc.kill() + await proc.wait() + + async def __aenter__(self) -> AsyncControlModeEngine: + """Start the engine on context entry.""" + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the engine on context exit.""" + await self.aclose() + + async def _reader(self) -> None: + """Background task: read tmux output, resolve futures, publish events.""" + proc = self._proc + if proc is None or proc.stdout is None: + return + stdout = proc.stdout + try: + while True: + chunk = await stdout.read(_READ_CHUNK) + if not chunk: + self._mark_dead(ControlModeError("tmux -C closed stdout")) + return + self._parser.feed(chunk) + for block in self._parser.blocks(): + self._dispatch_block(block) + for line in self._parser.notifications(): + self._publish(line) + except asyncio.CancelledError: + raise + except Exception as error: + self._mark_dead(ControlModeError(f"control-mode reader failed: {error}")) + + def _dispatch_block(self, block: t.Any) -> None: + """Resolve the next pending command, or skip an unsolicited block.""" + if not self._pending: + return # startup ACK or hook-triggered command: not ours, skip + pending = self._pending.popleft() + if not pending.future.done(): + pending.future.set_result(_result_from_block(block, pending.argv)) + + def _publish(self, line: bytes) -> None: + """Enqueue a notification, dropping the oldest on overflow.""" + notification = ControlNotification.parse(line) + try: + self._events.put_nowait(notification) + except asyncio.QueueFull: + self._dropped_notifications += 1 + with contextlib.suppress(asyncio.QueueEmpty): + self._events.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + self._events.put_nowait(notification) + + def _mark_dead(self, error: BaseException) -> None: + """Record the engine as dead and fail all pending commands.""" + if self._dead is None: + self._dead = error + self._fail_pending(error) + + def _fail_pending(self, error: BaseException) -> None: + """Fail every queued command future with *error*.""" + while self._pending: + pending = self._pending.popleft() + if not pending.future.done(): + pending.future.set_exception(error) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> AsyncControlModeEngine: + """Build an async control-mode engine bound to a live server's socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls( + tmux_bin=getattr(server, "tmux_bin", None), + server_args=server_args, + **kwargs, + ) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index 07461d630..841c4f8c8 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -1,11 +1,12 @@ -"""A deterministic, in-memory engine for tests and docs (no tmux server). +"""Deterministic, in-memory engines for tests and docs (no tmux server). -The concrete engine simulates just enough tmux behaviour to exercise the -operation contract without a live server: creation commands that ask for an id +The concrete engines simulate just enough tmux behaviour to exercise the +operation contract offline: creation commands that ask for an id (``-P -F '#{pane_id}'``) get a fabricated, monotonic id, ``capture-pane`` returns -canned lines, and everything else succeeds with empty output. This is what backs -doctests and the cross-engine contract suite, so examples run anywhere and the -"same typed result regardless of engine" invariant can be asserted offline. +canned lines, and everything else succeeds with empty output. A sync +(:class:`ConcreteEngine`) and async (:class:`AsyncConcreteEngine`) variant share +the same simulation, so the same operation returns the same typed result through +either, with no tmux required. """ from __future__ import annotations @@ -20,8 +21,40 @@ from libtmux.experimental.engines.base import CommandRequest +def _fabricate(fmt: str, counters: dict[str, int]) -> str: + """Return the next fabricated id for a ``#{..._id}`` capture format.""" + for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): + if key in fmt: + counters[key] += 1 + return f"{sigil}{counters[key]}" + return "?" + + +def _simulate( + argv: tuple[str, ...], + counters: dict[str, int], + capture_lines: tuple[str, ...], +) -> CommandResult: + """Produce a deterministic result for a rendered tmux command.""" + if "-P" in argv and "-F" in argv: + fmt = argv[argv.index("-F") + 1] + return CommandResult( + cmd=("tmux", *argv), + stdout=(_fabricate(fmt, counters),), + returncode=0, + ) + if argv and argv[0] == "capture-pane": + return CommandResult(cmd=("tmux", *argv), stdout=capture_lines, returncode=0) + return CommandResult(cmd=("tmux", *argv), returncode=0) + + +def _new_counters() -> dict[str, int]: + """Return a fresh id-counter map.""" + return {"pane_id": 0, "window_id": 0, "session_id": 0} + + class ConcreteEngine: - """Execute operations against an in-memory simulation. + """Execute operations against an in-memory simulation (synchronous). Parameters ---------- @@ -43,34 +76,42 @@ class ConcreteEngine: def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: self.capture_lines = tuple(capture_lines) - self._counters = {"pane_id": 0, "window_id": 0, "session_id": 0} - - def _fabricate(self, fmt: str) -> str: - """Return the next fabricated id for a ``#{..._id}`` capture format.""" - for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): - if key in fmt: - self._counters[key] += 1 - return f"{sigil}{self._counters[key]}" - return "?" + self._counters = _new_counters() def run(self, request: CommandRequest) -> CommandResult: """Execute one request against the in-memory simulation.""" - argv = request.args - if "-P" in argv and "-F" in argv: - fmt = argv[argv.index("-F") + 1] - return CommandResult( - cmd=("tmux", *argv), - stdout=(self._fabricate(fmt),), - returncode=0, - ) - if argv and argv[0] == "capture-pane": - return CommandResult( - cmd=("tmux", *argv), - stdout=self.capture_lines, - returncode=0, - ) - return CommandResult(cmd=("tmux", *argv), returncode=0) + return _simulate(request.args, self._counters, self.capture_lines) def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: """Execute each request in order (no batching benefit).""" return [self.run(req) for req in requests] + + +class AsyncConcreteEngine: + """Async sibling of :class:`ConcreteEngine` for offline async tests/docs. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SplitWindow, arun + >>> from libtmux.experimental.ops._types import WindowId + >>> async def main(): + ... return await arun(SplitWindow(target=WindowId("@1")), AsyncConcreteEngine()) + >>> asyncio.run(main()).new_pane_id + '%1' + """ + + def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: + self.capture_lines = tuple(capture_lines) + self._counters = _new_counters() + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one request against the in-memory simulation.""" + return _simulate(request.args, self._counters, self.capture_lines) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute each request in order (no batching benefit).""" + return [await self.run(req) for req in requests] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index ae113b672..73f069661 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -85,11 +85,12 @@ class ControlModeParser: (True, (b'boom',)) """ - __slots__ = ("_blocks", "_buffer", "_pending") + __slots__ = ("_blocks", "_buffer", "_notifications", "_pending") def __init__(self) -> None: self._buffer = bytearray() self._blocks: list[ControlModeBlock] = [] + self._notifications: list[bytes] = [] self._pending: _PendingBlock | None = None def feed(self, data: bytes) -> None: @@ -110,6 +111,17 @@ def blocks(self) -> list[ControlModeBlock]: blocks, self._blocks = self._blocks, [] return blocks + def notifications(self) -> list[bytes]: + """Drain raw ``%``-notification lines seen outside command blocks. + + Control mode wraps *command output* in ``%begin``/``%end`` blocks but + emits asynchronous notifications (``%output``, ``%window-add``, ...) as + bare lines. The sync engine ignores these; the async engine routes them + to its event stream. + """ + notifications, self._notifications = self._notifications, [] + return notifications + def _handle_line(self, line: bytes) -> None: if self._pending is not None: if _matches_pending_close(line, self._pending.number): @@ -119,6 +131,8 @@ def _handle_line(self, line: bytes) -> None: return if line.startswith(_BEGIN_PREFIX): self._open_block(line) + elif line.startswith(b"%"): + self._notifications.append(line) def _open_block(self, line: bytes) -> None: number, flags = _parse_guard(line, _BEGIN_PREFIX) diff --git a/tests/experimental/contract/test_async_control_engine.py b/tests/experimental/contract/test_async_control_engine.py new file mode 100644 index 000000000..b6d9ec5ac --- /dev/null +++ b/tests/experimental/contract/test_async_control_engine.py @@ -0,0 +1,98 @@ +"""Async control-mode engine against a real tmux server. + +Drives the persistent async ``tmux -C`` engine end to end via :func:`asyncio.run` +and asserts it returns the same typed result the other engines do, plus that its +notification stream works. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import ( + AsyncConcreteEngine, + AsyncControlModeEngine, + ControlNotification, +) +from libtmux.experimental.ops import SplitWindow, arun +from libtmux.experimental.ops._types import WindowId +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_notification_parse() -> None: + """A raw notification line parses into a typed notification (no tmux).""" + notif = ControlNotification.parse(b"%window-add @3") + assert notif.kind == "window-add" + assert notif.args == ("@3",) + + +def test_async_control_split_creates_real_pane(session: Session) -> None: + """An async control-mode split returns a typed result; the pane exists.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> SplitWindowResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun(SplitWindow(target=WindowId(window_id)), engine) + + result = asyncio.run(main()) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_async_control_batches_pipelined(session: Session) -> None: + """run_batch pipelines several splits over one connection, one result each.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> tuple[str | None, str | None]: + async with AsyncControlModeEngine.for_server(server) as engine: + r1 = await arun(SplitWindow(target=WindowId(window_id)), engine) + r2 = await arun(SplitWindow(target=WindowId(window_id)), engine) + return r1.new_pane_id, r2.new_pane_id + + first, second = asyncio.run(main()) + assert first is not None + assert second is not None + assert first != second + + +def test_async_control_concrete_parity(session: Session) -> None: + """Async control-mode and concrete engines agree on result type and argv.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + operation = SplitWindow(target=WindowId(window_id)) + + async def main() -> SplitWindowResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun(operation, engine) + + control = asyncio.run(main()) + concrete = asyncio.run(arun(operation, AsyncConcreteEngine())) + assert type(control) is type(concrete) is SplitWindowResult + assert control.argv == concrete.argv == operation.render() + + +def test_async_control_event_stream(session: Session) -> None: + """A command that changes server state surfaces a notification on the stream.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> ControlNotification: + async with AsyncControlModeEngine.for_server(server) as engine: + events = engine.subscribe() + await arun(SplitWindow(target=WindowId(window_id)), engine) + return await asyncio.wait_for(anext(events), timeout=10.0) + + notif = asyncio.run(main()) + assert notif.kind + assert notif.raw.startswith("%") diff --git a/tests/experimental/ops/test_control_parser.py b/tests/experimental/ops/test_control_parser.py index fc29bb9a4..7517e330c 100644 --- a/tests/experimental/ops/test_control_parser.py +++ b/tests/experimental/ops/test_control_parser.py @@ -41,9 +41,26 @@ def test_blocks_drains() -> None: def test_ignores_noise_outside_blocks() -> None: - """Notification lines outside a block are ignored by the command parser.""" + """Notification lines outside a block are not command blocks.""" parser = ControlModeParser() parser.feed(b"%output %1 hi\n%begin 1 1 1\nok\n%end 1 1 1\n") blocks = parser.blocks() assert len(blocks) == 1 assert blocks[0].body == (b"ok",) + + +def test_surfaces_notifications() -> None: + """Bare ``%`` lines outside blocks are surfaced as notifications.""" + parser = ControlModeParser() + parser.feed(b"%window-add @3\n%begin 1 1 1\nok\n%end 1 1 1\n%output %1 hi\n") + assert parser.notifications() == [b"%window-add @3", b"%output %1 hi"] + # block lines are not double-counted as notifications + assert parser.blocks()[0].body == (b"ok",) + + +def test_notifications_drain() -> None: + """``notifications`` returns each notification once, then is empty.""" + parser = ControlModeParser() + parser.feed(b"%window-close @3\n") + assert parser.notifications() == [b"%window-close @3"] + assert parser.notifications() == [] diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index bbba688e7..317a953cb 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -import typing as t import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.ops import ( LazyPlan, SendKeys, @@ -16,29 +15,6 @@ from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.exc import OperationError -if t.TYPE_CHECKING: - from collections.abc import Sequence - - from libtmux.experimental.engines.base import CommandRequest, CommandResult - - -class _AsyncConcreteEngine: - """Async wrapper over :class:`ConcreteEngine` for plan.aexecute tests.""" - - def __init__(self) -> None: - self._inner = ConcreteEngine() - - async def run(self, request: CommandRequest) -> CommandResult: - """Run synchronously under the hood; awaitable for the async driver.""" - return self._inner.run(request) - - async def run_batch( - self, - requests: Sequence[CommandRequest], - ) -> list[CommandResult]: - """Execute each request in order.""" - return [await self.run(req) for req in requests] - def test_plan_records_without_executing() -> None: """Building a plan touches no engine; it just records operations.""" @@ -68,7 +44,7 @@ def test_plan_aexecute_matches_execute() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) plan.add(SendKeys(target=pane, keys="vim", enter=True)) - outcome = asyncio.run(plan.aexecute(_AsyncConcreteEngine())) + outcome = asyncio.run(plan.aexecute(AsyncConcreteEngine())) assert outcome.bindings == {0: "%1"} assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") From 63239e2bafc12d79c2c8af12817e313f2dcd4df0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 10:18:56 -0500 Subject: [PATCH 007/223] Ops(feat): Add AckResult for no-output operations why: Many tmux commands print nothing (rename-window, kill-pane, select-window, ...). tmux returns CMD_RETURN_NORMAL on success or calls cmdq_error on failure, framed in control mode as %end vs %error (see tmux cmd-queue.c) -- they never cmdq_print. They still need a typed result that records success/failure without inventing a payload. what: - results.AckResult: a typed acknowledgement (no payload) whose raise_for_status() still surfaces the error path; documents the tmux success/error mapping - retarget send-keys and select-layout to AckResult (both print nothing) - add no-output ops: rename-window (mutating), kill-window and kill-pane (destructive) -- exercising AckResult across scopes and safety tiers - export AckResult and the new ops; refresh the catalog doctest - tests: render + AckResult success/failure across the no-output ops and destructive safety metadata; update classic/control parity assertions --- src/libtmux/experimental/ops/__init__.py | 8 ++ src/libtmux/experimental/ops/_ops/__init__.py | 6 ++ .../experimental/ops/_ops/kill_pane.py | 41 ++++++++ .../experimental/ops/_ops/kill_window.py | 43 +++++++++ .../experimental/ops/_ops/rename_window.py | 41 ++++++++ .../experimental/ops/_ops/select_layout.py | 6 +- .../experimental/ops/_ops/send_keys.py | 6 +- src/libtmux/experimental/ops/catalog.py | 3 +- src/libtmux/experimental/ops/results.py | 27 ++++++ .../contract/test_classic_engine.py | 4 +- .../contract/test_control_engine.py | 4 +- tests/experimental/ops/test_ack_ops.py | 93 +++++++++++++++++++ 12 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/kill_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/kill_window.py create mode 100644 src/libtmux/experimental/ops/_ops/rename_window.py create mode 100644 tests/experimental/ops/test_ack_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 0e069dbd6..73c34617c 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -21,6 +21,9 @@ from libtmux.experimental.ops._ops import ( CapturePane, + KillPane, + KillWindow, + RenameWindow, SelectLayout, SendKeys, SplitWindow, @@ -59,6 +62,7 @@ registry, ) from libtmux.experimental.ops.results import ( + AckResult, CapturePaneResult, Result, SplitWindowResult, @@ -74,6 +78,7 @@ ) __all__ = ( + "AckResult", "CapturePane", "CapturePaneResult", "CatalogEntry", @@ -81,6 +86,8 @@ "DuplicateOperation", "Effects", "IndexRef", + "KillPane", + "KillWindow", "LazyPlan", "NameRef", "OpSpec", @@ -89,6 +96,7 @@ "OperationRegistry", "PaneId", "PlanResult", + "RenameWindow", "Result", "Safety", "Scope", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 8c050ec44..d5152fd0d 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -8,12 +8,18 @@ from __future__ import annotations from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.kill_pane import KillPane +from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.split_window import SplitWindow __all__ = ( "CapturePane", + "KillPane", + "KillWindow", + "RenameWindow", "SelectLayout", "SendKeys", "SplitWindow", diff --git a/src/libtmux/experimental/ops/_ops/kill_pane.py b/src/libtmux/experimental/ops/_ops/kill_pane.py new file mode 100644 index 000000000..7e835fc4f --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_pane.py @@ -0,0 +1,41 @@ +"""The ``kill-pane`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillPane(Operation[AckResult]): + """Kill a pane. Destructive; produces no output (:class:`AckResult`). + + Parameters + ---------- + others : bool + Kill all panes *except* the target (``-a``) instead of the target. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> KillPane(target=PaneId("%1")).render() + ('kill-pane', '-t', '%1') + """ + + kind = "kill_pane" + command = "kill-pane" + scope = "pane" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + others: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.others else () diff --git a/src/libtmux/experimental/ops/_ops/kill_window.py b/src/libtmux/experimental/ops/_ops/kill_window.py new file mode 100644 index 000000000..f70a5f987 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_window.py @@ -0,0 +1,43 @@ +"""The ``kill-window`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillWindow(Operation[AckResult]): + """Kill a window. Destructive; produces no output (:class:`AckResult`). + + Parameters + ---------- + others : bool + Kill all windows *except* the target (``-a``) instead of the target. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> KillWindow(target=WindowId("@1")).render() + ('kill-window', '-t', '@1') + >>> KillWindow(target=WindowId("@1"), others=True).render() + ('kill-window', '-t', '@1', '-a') + """ + + kind = "kill_window" + command = "kill-window" + scope = "window" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + others: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.others else () diff --git a/src/libtmux/experimental/ops/_ops/rename_window.py b/src/libtmux/experimental/ops/_ops/rename_window.py new file mode 100644 index 000000000..4cae1a96f --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rename_window.py @@ -0,0 +1,41 @@ +"""The ``rename-window`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RenameWindow(Operation[AckResult]): + """Rename a window. Produces no output; returns an :class:`AckResult`. + + Parameters + ---------- + name : str + The new window name. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RenameWindow(target=WindowId("@1"), name="build").render() + ('rename-window', '-t', '@1', 'build') + """ + + kind = "rename_window" + command = "rename-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + name: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the new name.""" + return (self.name,) diff --git a/src/libtmux/experimental/ops/_ops/select_layout.py b/src/libtmux/experimental/ops/_ops/select_layout.py index f17af37b4..01dfe1682 100644 --- a/src/libtmux/experimental/ops/_ops/select_layout.py +++ b/src/libtmux/experimental/ops/_ops/select_layout.py @@ -7,12 +7,12 @@ from libtmux.experimental.ops._types import Effects from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.registry import register -from libtmux.experimental.ops.results import Result +from libtmux.experimental.ops.results import AckResult @register @dataclass(frozen=True, kw_only=True) -class SelectLayout(Operation[Result]): +class SelectLayout(Operation[AckResult]): """Apply a layout to a window. Parameters @@ -33,7 +33,7 @@ class SelectLayout(Operation[Result]): kind = "select_layout" command = "select-layout" scope = "window" - result_cls = Result + result_cls = AckResult safety = "mutating" effects = Effects(idempotent=True, mutates_layout=True) diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py index 81de969fa..e7c009796 100644 --- a/src/libtmux/experimental/ops/_ops/send_keys.py +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -7,12 +7,12 @@ from libtmux.experimental.ops._types import Effects from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.registry import register -from libtmux.experimental.ops.results import Result +from libtmux.experimental.ops.results import AckResult @register @dataclass(frozen=True, kw_only=True) -class SendKeys(Operation[Result]): +class SendKeys(Operation[AckResult]): """Send keys (input) to a pane. Parameters @@ -36,7 +36,7 @@ class SendKeys(Operation[Result]): kind = "send_keys" command = "send-keys" scope = "pane" - result_cls = Result + result_cls = AckResult safety = "mutating" effects = Effects(writes_input=True) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index b0a272e67..d7012a325 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,7 +65,8 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'select_layout', 'send_keys', 'split_window'] + ['capture_pane', 'kill_pane', 'kill_window', 'rename_window', + 'select_layout', 'send_keys', 'split_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 70951a4e9..b275171b8 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -149,6 +149,33 @@ def raise_for_status(self) -> Self: return self +@dataclass(frozen=True) +class AckResult(Result): + """Result of an operation that returns no data -- only success or failure. + + Many tmux commands (``rename-window``, ``kill-pane``, ``select-window``, ...) + print nothing. In the CLI they exit ``0`` on success or write to stderr and + exit nonzero on failure; in control mode tmux frames them as ``%end`` + (success) or ``%error`` (failure) -- it never calls ``cmdq_print`` for them + (see tmux ``cmd-queue.c``). An :class:`AckResult` is the typed + acknowledgement for exactly that case: no payload, but + :meth:`~Result.raise_for_status` still surfaces the error path, because a + no-output command can still fail. + + Examples + -------- + >>> from libtmux.experimental.ops import RenameWindow + >>> from libtmux.experimental.ops._types import WindowId + >>> op = RenameWindow(target=WindowId("@1"), name="build") + >>> ok = op.build_result(returncode=0) + >>> type(ok).__name__, ok.ok + ('AckResult', True) + >>> failed = op.build_result(returncode=1, stderr=("can't find window @1",)) + >>> failed.ok + False + """ + + @dataclass(frozen=True) class SplitWindowResult(Result): """Result of a ``split-window`` operation. diff --git a/tests/experimental/contract/test_classic_engine.py b/tests/experimental/contract/test_classic_engine.py index f3a889646..927a6be3a 100644 --- a/tests/experimental/contract/test_classic_engine.py +++ b/tests/experimental/contract/test_classic_engine.py @@ -20,8 +20,8 @@ ) from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.results import ( + AckResult, CapturePaneResult, - Result, SplitWindowResult, ) @@ -56,7 +56,7 @@ def test_classic_send_keys_and_select_layout(session: Session) -> None: engine = SubprocessEngine.for_server(server) sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) - assert type(sent) is Result + assert type(sent) is AckResult assert sent.ok laid_out = run( diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py index 07966326e..73aa1a6f8 100644 --- a/tests/experimental/contract/test_control_engine.py +++ b/tests/experimental/contract/test_control_engine.py @@ -18,8 +18,8 @@ ) from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.results import ( + AckResult, CapturePaneResult, - Result, SplitWindowResult, ) @@ -55,7 +55,7 @@ def test_control_batches_multiple_commands(session: Session) -> None: sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) - assert type(sent) is Result + assert type(sent) is AckResult assert sent.ok assert isinstance(captured, CapturePaneResult) assert captured.ok diff --git a/tests/experimental/ops/test_ack_ops.py b/tests/experimental/ops/test_ack_ops.py new file mode 100644 index 000000000..2ccc63ee8 --- /dev/null +++ b/tests/experimental/ops/test_ack_ops.py @@ -0,0 +1,93 @@ +"""Tests for no-output operations and the AckResult type.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + KillPane, + KillWindow, + RenameWindow, + SelectLayout, + SendKeys, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import TmuxCommandError +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + + +@pytest.mark.parametrize( + ("operation", "expected_argv"), + [ + pytest.param( + RenameWindow(target=WindowId("@1"), name="build"), + ("rename-window", "-t", "@1", "build"), + id="rename_window", + ), + pytest.param( + KillWindow(target=WindowId("@1")), + ("kill-window", "-t", "@1"), + id="kill_window", + ), + pytest.param( + KillPane(target=PaneId("%1")), + ("kill-pane", "-t", "%1"), + id="kill_pane", + ), + pytest.param( + SendKeys(target=PaneId("%1"), keys="x"), + ("send-keys", "-t", "%1", "x"), + id="send_keys", + ), + pytest.param( + SelectLayout(target=WindowId("@1"), layout="tiled"), + ("select-layout", "-t", "@1", "tiled"), + id="select_layout", + ), + ], +) +def test_no_output_ops_return_ack( + operation: Operation[AckResult], + expected_argv: tuple[str, ...], +) -> None: + """No-output operations render correctly and yield an AckResult.""" + result = run(operation, ConcreteEngine()) + assert type(result) is AckResult + assert result.argv == expected_argv + assert result.ok + + +def test_ack_success_has_no_payload() -> None: + """A successful ack carries only status -- no extra fields beyond the base.""" + result = RenameWindow(target=WindowId("@1"), name="x").build_result(returncode=0) + assert isinstance(result, AckResult) + assert result.ok + assert result.stdout == () + + +def test_ack_failure_raises_on_demand() -> None: + """A no-output command can still fail; raise_for_status surfaces it.""" + result = KillWindow(target=WindowId("@9")).build_result( + returncode=1, + stderr=("can't find window @9",), + ) + assert result.failed + with pytest.raises(TmuxCommandError): + result.raise_for_status() + + +def test_destructive_safety_metadata() -> None: + """Kill operations are tagged destructive in the registry/catalog.""" + from libtmux.experimental.ops import catalog + + safety = {entry.kind: entry.safety for entry in catalog()} + assert safety["kill_window"] == "destructive" + assert safety["kill_pane"] == "destructive" + assert safety["rename_window"] == "mutating" From 20876d530a21a7c3235445e239ef4ad2a17f9274 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 10:18:56 -0500 Subject: [PATCH 008/223] Models(feat): Add pure object-graph snapshots why: A neo-like read model is useful, but neo.Obj is one flat ~200-field class fused to the query/dispatch pipeline. The experimental namespace lets us try a decoupled, immutable, serializable snapshot layer without any risk to the shipped ORM APIs. what: - libtmux.experimental.models: frozen PaneSnapshot / WindowSnapshot / SessionSnapshot / ServerSnapshot, each a typed core plus the full raw tmux-format tail in .fields (nothing tmux reported is lost) - from_format() builds one node from a format mapping; ServerSnapshot.from_pane_rows() groups a flat "list-panes -a -F" row set into an ordered session/window/pane tree - to_dict()/from_dict() round-trip the whole tree as plain data, with no live objects - pure tests (no tmux): value coercion, tree grouping/order, round-trip --- src/libtmux/experimental/models/__init__.py | 25 ++ src/libtmux/experimental/models/snapshots.py | 308 +++++++++++++++++++ tests/experimental/models/__init__.py | 3 + tests/experimental/models/test_snapshots.py | 134 ++++++++ 4 files changed, 470 insertions(+) create mode 100644 src/libtmux/experimental/models/__init__.py create mode 100644 src/libtmux/experimental/models/snapshots.py create mode 100644 tests/experimental/models/__init__.py create mode 100644 tests/experimental/models/test_snapshots.py diff --git a/src/libtmux/experimental/models/__init__.py b/src/libtmux/experimental/models/__init__.py new file mode 100644 index 000000000..82866f450 --- /dev/null +++ b/src/libtmux/experimental/models/__init__.py @@ -0,0 +1,25 @@ +"""Pure, immutable snapshots of the tmux object graph. + +A neo-like view of server/session/window/pane state as plain *values* -- no live +:class:`~libtmux.Server`, no command dispatch, no coupling to the existing ORM or +query pipeline. Snapshots are immutable, composable into a tree, and serializable, +so they are safe to experiment with under :mod:`libtmux.experimental` without +touching shipped APIs. See the operationalization plan (``tmux-python/libtmux`` +issue 689). +""" + +from __future__ import annotations + +from libtmux.experimental.models.snapshots import ( + PaneSnapshot, + ServerSnapshot, + SessionSnapshot, + WindowSnapshot, +) + +__all__ = ( + "PaneSnapshot", + "ServerSnapshot", + "SessionSnapshot", + "WindowSnapshot", +) diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py new file mode 100644 index 000000000..9a8e4e808 --- /dev/null +++ b/src/libtmux/experimental/models/snapshots.py @@ -0,0 +1,308 @@ +"""Pure, immutable snapshots of the tmux object graph. + +These are *values*, not live objects: a snapshot captures the state of a server, +session, window, or pane at one moment, with no reference to a +:class:`~libtmux.Server` and no ability to issue tmux commands. They resemble +:class:`libtmux.neo.Obj` but are decoupled from the query/dispatch pipeline and +from each other, so experimenting with them cannot affect the existing ORM APIs. + +Each snapshot keeps a typed *core* of the most-used fields plus the full raw +format mapping in :attr:`fields`, so nothing tmux reported is lost. Snapshots +compose into a tree (:class:`ServerSnapshot` → :class:`SessionSnapshot` → +:class:`WindowSnapshot` → :class:`PaneSnapshot`), can be built from a single +``list-panes -a -F`` style row set via :meth:`ServerSnapshot.from_pane_rows`, +and round-trip to plain dicts for serialization or diffing. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field, replace + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Mapping + +_TRUE = {"1", "on", "yes", "true"} + + +def _as_int(value: str | None) -> int | None: + """Coerce a tmux format value to ``int``, or ``None`` if absent/non-numeric. + + Examples + -------- + >>> _as_int("3") + 3 + >>> _as_int("") is None + True + >>> _as_int(None) is None + True + """ + if value is None or value == "": + return None + try: + return int(value) + except ValueError: + return None + + +def _as_bool(value: str | None) -> bool: + """Coerce a tmux flag value (``"1"``/``"0"``/``""``) to ``bool``. + + Examples + -------- + >>> _as_bool("1") + True + >>> _as_bool("0") + False + >>> _as_bool(None) + False + """ + return value is not None and value.lower() in _TRUE + + +@dataclass(frozen=True) +class PaneSnapshot: + """An immutable snapshot of one tmux pane. + + Examples + -------- + >>> pane = PaneSnapshot.from_format({ + ... "pane_id": "%1", "pane_index": "0", "window_id": "@1", + ... "session_id": "$0", "pane_active": "1", "pane_width": "80", + ... "pane_height": "24", "pane_current_command": "zsh", + ... }) + >>> pane.pane_id, pane.pane_index, pane.active, pane.width + ('%1', 0, True, 80) + >>> pane.current_command + 'zsh' + """ + + pane_id: str = "" + pane_index: int | None = None + window_id: str = "" + session_id: str = "" + active: bool = False + width: int | None = None + height: int | None = None + current_command: str | None = None + current_path: str | None = None + title: str | None = None + pid: int | None = None + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> PaneSnapshot: + """Build a pane snapshot from a raw tmux format mapping.""" + return cls( + pane_id=raw.get("pane_id", ""), + pane_index=_as_int(raw.get("pane_index")), + window_id=raw.get("window_id", ""), + session_id=raw.get("session_id", ""), + active=_as_bool(raw.get("pane_active")), + width=_as_int(raw.get("pane_width")), + height=_as_int(raw.get("pane_height")), + current_command=raw.get("pane_current_command"), + current_path=raw.get("pane_current_path"), + title=raw.get("pane_title"), + pid=_as_int(raw.get("pane_pid")), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict (raw fields only; typed core re-derives).""" + return {"fields": dict(self.fields)} + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> PaneSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return cls.from_format(data["fields"]) + + +@dataclass(frozen=True) +class WindowSnapshot: + """An immutable snapshot of one tmux window and its panes. + + Examples + -------- + >>> win = WindowSnapshot.from_format({ + ... "window_id": "@1", "window_index": "0", "window_name": "main", + ... "session_id": "$0", "window_active": "1", + ... }) + >>> win.window_id, win.window_index, win.name, win.active + ('@1', 0, 'main', True) + >>> win.panes + () + """ + + window_id: str = "" + window_index: int | None = None + name: str | None = None + session_id: str = "" + active: bool = False + layout: str | None = None + panes: tuple[PaneSnapshot, ...] = () + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> WindowSnapshot: + """Build a window snapshot from a raw tmux format mapping.""" + return cls( + window_id=raw.get("window_id", ""), + window_index=_as_int(raw.get("window_index")), + name=raw.get("window_name"), + session_id=raw.get("session_id", ""), + active=_as_bool(raw.get("window_active")), + layout=raw.get("window_layout"), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict, including child panes.""" + return { + "fields": dict(self.fields), + "panes": [pane.to_dict() for pane in self.panes], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> WindowSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return replace( + cls.from_format(data["fields"]), + panes=tuple(PaneSnapshot.from_dict(p) for p in data.get("panes", [])), + ) + + +@dataclass(frozen=True) +class SessionSnapshot: + """An immutable snapshot of one tmux session and its windows.""" + + session_id: str = "" + name: str | None = None + attached: bool = False + windows: tuple[WindowSnapshot, ...] = () + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> SessionSnapshot: + """Build a session snapshot from a raw tmux format mapping.""" + return cls( + session_id=raw.get("session_id", ""), + name=raw.get("session_name"), + attached=_as_bool(raw.get("session_attached")), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict, including child windows.""" + return { + "fields": dict(self.fields), + "windows": [window.to_dict() for window in self.windows], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> SessionSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return replace( + cls.from_format(data["fields"]), + windows=tuple(WindowSnapshot.from_dict(w) for w in data.get("windows", [])), + ) + + +@dataclass(frozen=True) +class ServerSnapshot: + """An immutable snapshot of a tmux server's session/window/pane tree. + + Examples + -------- + Build the whole graph from a flat ``list-panes -a -F`` style row set: + + >>> rows = [ + ... {"session_id": "$0", "session_name": "work", "window_id": "@1", + ... "window_index": "0", "window_name": "main", "pane_id": "%1", + ... "pane_index": "0", "pane_active": "1"}, + ... {"session_id": "$0", "session_name": "work", "window_id": "@1", + ... "window_index": "0", "window_name": "main", "pane_id": "%2", + ... "pane_index": "1", "pane_active": "0"}, + ... ] + >>> server = ServerSnapshot.from_pane_rows(rows, socket_name="default") + >>> [s.name for s in server.sessions] + ['work'] + >>> [p.pane_id for p in server.sessions[0].windows[0].panes] + ['%1', '%2'] + """ + + socket_name: str | None = None + socket_path: str | None = None + sessions: tuple[SessionSnapshot, ...] = () + + @classmethod + def from_pane_rows( + cls, + rows: Iterable[Mapping[str, str]], + *, + socket_name: str | None = None, + socket_path: str | None = None, + ) -> ServerSnapshot: + """Group flat per-pane rows into a session/window/pane tree. + + Each row is one pane's format mapping carrying its ``session_*`` and + ``window_*`` fields too (as ``tmux list-panes -a -F`` yields). The first + row seen for a session/window supplies that level's fields; insertion + order is preserved. + """ + session_order: list[str] = [] + session_fields: dict[str, Mapping[str, str]] = {} + window_order: dict[str, list[str]] = {} + window_fields: dict[str, Mapping[str, str]] = {} + window_panes: dict[str, list[PaneSnapshot]] = {} + + for row in rows: + session_id = row.get("session_id", "") + window_id = row.get("window_id", "") + if session_id not in session_fields: + session_fields[session_id] = row + session_order.append(session_id) + window_order[session_id] = [] + if window_id not in window_fields: + window_fields[window_id] = row + window_order[session_id].append(window_id) + window_panes[window_id] = [] + window_panes[window_id].append(PaneSnapshot.from_format(row)) + + sessions = tuple( + replace( + SessionSnapshot.from_format(session_fields[session_id]), + windows=tuple( + replace( + WindowSnapshot.from_format(window_fields[window_id]), + panes=tuple(window_panes[window_id]), + ) + for window_id in window_order[session_id] + ), + ) + for session_id in session_order + ) + return cls( + socket_name=socket_name, + socket_path=socket_path, + sessions=sessions, + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize the whole tree to plain data.""" + return { + "socket_name": self.socket_name, + "socket_path": self.socket_path, + "sessions": [session.to_dict() for session in self.sessions], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> ServerSnapshot: + """Reconstruct the whole tree from :meth:`to_dict` output.""" + return cls( + socket_name=data.get("socket_name"), + socket_path=data.get("socket_path"), + sessions=tuple( + SessionSnapshot.from_dict(s) for s in data.get("sessions", []) + ), + ) diff --git a/tests/experimental/models/__init__.py b/tests/experimental/models/__init__.py new file mode 100644 index 000000000..eb117f032 --- /dev/null +++ b/tests/experimental/models/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.models.""" + +from __future__ import annotations diff --git a/tests/experimental/models/test_snapshots.py b/tests/experimental/models/test_snapshots.py new file mode 100644 index 000000000..61238b7b5 --- /dev/null +++ b/tests/experimental/models/test_snapshots.py @@ -0,0 +1,134 @@ +"""Tests for the pure object-graph snapshots.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.models import ( + PaneSnapshot, + ServerSnapshot, + WindowSnapshot, +) +from libtmux.experimental.models.snapshots import _as_bool, _as_int + + +@pytest.mark.parametrize( + ("value", "expected"), + [("3", 3), ("0", 0), ("", None), (None, None), ("nope", None)], +) +def test_as_int(value: str | None, expected: int | None) -> None: + """Format values coerce to int or None.""" + assert _as_int(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [("1", True), ("0", False), ("", False), (None, False), ("on", True)], +) +def test_as_bool(value: str | None, expected: bool) -> None: + """Flag values coerce to bool.""" + assert _as_bool(value) is expected + + +def test_pane_from_format_typed_core() -> None: + """A pane snapshot exposes a typed core derived from the raw mapping.""" + pane = PaneSnapshot.from_format( + { + "pane_id": "%1", + "pane_index": "2", + "pane_active": "1", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "vim", + }, + ) + assert pane.pane_id == "%1" + assert pane.pane_index == 2 + assert pane.active is True + assert pane.width == 80 + assert pane.current_command == "vim" + + +def test_raw_fields_preserved() -> None: + """The full raw mapping is retained even for un-promoted fields.""" + pane = PaneSnapshot.from_format({"pane_id": "%1", "pane_tty": "/dev/pts/3"}) + assert pane.fields["pane_tty"] == "/dev/pts/3" + + +def test_window_from_format_has_empty_panes() -> None: + """A window built from a format mapping starts with no panes.""" + window = WindowSnapshot.from_format({"window_id": "@1", "window_name": "main"}) + assert window.panes == () + + +def test_from_pane_rows_builds_tree_in_order() -> None: + """Flat pane rows group into an ordered session/window/pane tree.""" + rows = [ + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%2", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@2", + "window_index": "1", + "pane_id": "%3", + }, + { + "session_id": "$1", + "session_name": "b", + "window_id": "@3", + "window_index": "0", + "pane_id": "%4", + }, + ] + server = ServerSnapshot.from_pane_rows(rows, socket_name="test") + + assert server.socket_name == "test" + assert [s.session_id for s in server.sessions] == ["$0", "$1"] + first = server.sessions[0] + assert first.name == "a" + assert [w.window_id for w in first.windows] == ["@1", "@2"] + assert [p.pane_id for p in first.windows[0].panes] == ["%1", "%2"] + assert [p.pane_id for p in first.windows[1].panes] == ["%3"] + assert server.sessions[1].windows[0].panes[0].pane_id == "%4" + + +def test_empty_rows_yield_empty_server() -> None: + """No rows produces a server snapshot with no sessions.""" + assert ServerSnapshot.from_pane_rows([]).sessions == () + + +def test_tree_round_trips_through_dict() -> None: + """A full server tree survives a to_dict / from_dict round-trip.""" + rows = [ + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + "pane_active": "1", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%2", + "pane_active": "0", + }, + ] + server = ServerSnapshot.from_pane_rows(rows, socket_name="test") + assert ServerSnapshot.from_dict(server.to_dict()) == server From 88d5ebc0cd7e9c7f39e049156b9c90f029f0fec3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 10:55:10 -0500 Subject: [PATCH 009/223] Ops(feat): Add read-seam list operations why: The list/show read commands overlap neo's reader. Rather than touch the ORM, add a parallel typed read surface in experimental.ops that yields immutable models snapshots. The render version must thread into result parsing first, because the -F template is version-gated and the parser must split against the same fields it was rendered with. what: - operation: thread `version` through build_result -> _make_result so payload parsing matches the version-gated render (backward compatible; existing overrides accept and ignore it); execute.run/arun pass it - ops._read: re-export neo.get_output_format / parse_output and formats.FORMAT_SEPARATOR as the single source of truth (no copies) - list-panes / list-windows / list-sessions ops (readonly, chainable=False) render the same -F template neo builds and parse rows into models snapshots - ListPanesResult/.../ store JSON-friendly rows and derive typed views (.panes/.server/.windows/.sessions) via properties, so results serialize and round-trip with no special-casing - tests: -F parity with neo, snapshot-tree build, serialize round-trip, and live list-panes/sessions/windows against a real tmux server --- src/libtmux/experimental/ops/__init__.py | 12 ++ src/libtmux/experimental/ops/_ops/__init__.py | 6 + .../experimental/ops/_ops/capture_pane.py | 1 + .../experimental/ops/_ops/list_panes.py | 92 +++++++++++++ .../experimental/ops/_ops/list_sessions.py | 73 +++++++++++ .../experimental/ops/_ops/list_windows.py | 81 ++++++++++++ .../experimental/ops/_ops/split_window.py | 1 + src/libtmux/experimental/ops/_read.py | 28 ++++ src/libtmux/experimental/ops/catalog.py | 4 +- src/libtmux/experimental/ops/execute.py | 2 + src/libtmux/experimental/ops/operation.py | 13 +- src/libtmux/experimental/ops/registry.py | 2 +- src/libtmux/experimental/ops/results.py | 55 ++++++++ tests/experimental/ops/test_read_ops.py | 121 ++++++++++++++++++ tests/experimental/ops/test_registry.py | 2 +- 15 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/list_panes.py create mode 100644 src/libtmux/experimental/ops/_ops/list_sessions.py create mode 100644 src/libtmux/experimental/ops/_ops/list_windows.py create mode 100644 src/libtmux/experimental/ops/_read.py create mode 100644 tests/experimental/ops/test_read_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 73c34617c..1947be8d5 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -23,6 +23,9 @@ CapturePane, KillPane, KillWindow, + ListPanes, + ListSessions, + ListWindows, RenameWindow, SelectLayout, SendKeys, @@ -64,6 +67,9 @@ from libtmux.experimental.ops.results import ( AckResult, CapturePaneResult, + ListPanesResult, + ListSessionsResult, + ListWindowsResult, Result, SplitWindowResult, status_for, @@ -89,6 +95,12 @@ "KillPane", "KillWindow", "LazyPlan", + "ListPanes", + "ListPanesResult", + "ListSessions", + "ListSessionsResult", + "ListWindows", + "ListWindowsResult", "NameRef", "OpSpec", "Operation", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index d5152fd0d..f0449faa4 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -10,6 +10,9 @@ from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.list_panes import ListPanes +from libtmux.experimental.ops._ops.list_sessions import ListSessions +from libtmux.experimental.ops._ops.list_windows import ListWindows from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys @@ -19,6 +22,9 @@ "CapturePane", "KillPane", "KillWindow", + "ListPanes", + "ListSessions", + "ListWindows", "RenameWindow", "SelectLayout", "SendKeys", diff --git a/src/libtmux/experimental/ops/_ops/capture_pane.py b/src/libtmux/experimental/ops/_ops/capture_pane.py index 598e0a3da..bd14f345a 100644 --- a/src/libtmux/experimental/ops/_ops/capture_pane.py +++ b/src/libtmux/experimental/ops/_ops/capture_pane.py @@ -97,6 +97,7 @@ def _make_result( returncode: int, stdout: tuple[str, ...], stderr: tuple[str, ...], + version: str | None = None, ) -> CapturePaneResult: """Expose captured stdout as typed :attr:`~.CapturePaneResult.lines`.""" return CapturePaneResult( diff --git a/src/libtmux/experimental/ops/_ops/list_panes.py b/src/libtmux/experimental/ops/_ops/list_panes.py new file mode 100644 index 000000000..702a4d740 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_panes.py @@ -0,0 +1,92 @@ +"""The ``list-panes`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListPanesResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListPanes(Operation[ListPanesResult]): + """List panes and return typed snapshots (a read operation). + + Renders the same ``-F`` template the ORM reader uses (via + :func:`libtmux.neo.get_output_format`) and parses each row into a + :class:`~libtmux.experimental.models.PaneSnapshot`; with ``all_panes`` the + result also exposes the full :class:`ServerSnapshot` tree. + + Parameters + ---------- + all_panes : bool + List panes across the whole server (``-a``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> op = ListPanes() + >>> op.render(version="3.6a")[:1] + ('list-panes',) + >>> "-a" in op.render(version="3.6a") + True + >>> result = run(op, ConcreteEngine(), version="3.6a") + >>> result.rows + () + >>> result.server.sessions + () + """ + + kind = "list_panes" + command = "list-panes" + scope = "server" + result_cls = ListPanesResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + all_panes: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-a`` (optional) and the ``-F`` format template.""" + _fields, fmt = get_output_format("list-panes", version or DEFAULT_LIST_VERSION) + out: list[str] = [] + if self.all_panes: + out.append("-a") + out.extend(("-F", fmt)) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListPanesResult: + """Parse each output row into a pane format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-panes", ver) for line in stdout if line) + return ListPanesResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_sessions.py b/src/libtmux/experimental/ops/_ops/list_sessions.py new file mode 100644 index 000000000..c120b06f1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_sessions.py @@ -0,0 +1,73 @@ +"""The ``list-sessions`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListSessionsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListSessions(Operation[ListSessionsResult]): + """List the server's sessions and return typed :class:`SessionSnapshot` rows. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListSessions(), ConcreteEngine(), version="3.6a").sessions + () + """ + + kind = "list_sessions" + command = "list-sessions" + scope = "server" + result_cls = ListSessionsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the ``-F`` format template (list-sessions is server-wide).""" + _fields, fmt = get_output_format( + "list-sessions", + version or DEFAULT_LIST_VERSION, + ) + return ("-F", fmt) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListSessionsResult: + """Parse each output row into a session format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple( + parse_output(line, "list-sessions", ver) for line in stdout if line + ) + return ListSessionsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_windows.py b/src/libtmux/experimental/ops/_ops/list_windows.py new file mode 100644 index 000000000..3126c58b3 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_windows.py @@ -0,0 +1,81 @@ +"""The ``list-windows`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListWindowsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListWindows(Operation[ListWindowsResult]): + """List windows and return typed :class:`WindowSnapshot` rows. + + Parameters + ---------- + all_windows : bool + List windows across the whole server (``-a``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListWindows(), ConcreteEngine(), version="3.6a").windows + () + """ + + kind = "list_windows" + command = "list-windows" + scope = "server" + result_cls = ListWindowsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + all_windows: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-a`` (optional) and the ``-F`` format template.""" + _fields, fmt = get_output_format( + "list-windows", version or DEFAULT_LIST_VERSION + ) + out: list[str] = [] + if self.all_windows: + out.append("-a") + out.extend(("-F", fmt)) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListWindowsResult: + """Parse each output row into a window format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-windows", ver) for line in stdout if line) + return ListWindowsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/split_window.py b/src/libtmux/experimental/ops/_ops/split_window.py index abaec4734..3179cedfa 100644 --- a/src/libtmux/experimental/ops/_ops/split_window.py +++ b/src/libtmux/experimental/ops/_ops/split_window.py @@ -93,6 +93,7 @@ def _make_result( returncode: int, stdout: tuple[str, ...], stderr: tuple[str, ...], + version: str | None = None, ) -> SplitWindowResult: """Parse the captured new-pane id into the typed result.""" new_pane_id = stdout[0].strip() if status == "complete" and stdout else None diff --git a/src/libtmux/experimental/ops/_read.py b/src/libtmux/experimental/ops/_read.py new file mode 100644 index 000000000..a05c4b98e --- /dev/null +++ b/src/libtmux/experimental/ops/_read.py @@ -0,0 +1,28 @@ +"""Shared helpers for read (list) operations. + +These re-export neo's format-template builder and output parser so the list +operations render the ``-F`` template and parse it with the *exact same* logic +the ORM's reader uses -- one source of truth, no drift. The list ops are a +separate, engine-pluggable read surface that yields immutable +:mod:`~libtmux.experimental.models` snapshots; neo itself is untouched. +""" + +from __future__ import annotations + +from libtmux.formats import FORMAT_SEPARATOR +from libtmux.neo import get_output_format, parse_output + +DEFAULT_LIST_VERSION = "3.2a" +"""tmux version assumed when the caller supplies none (the project floor). + +Rendering and parsing must use the *same* version, so a list op renders its +``-F`` template and parses its output at this version unless an explicit one is +threaded through. Older = a safe field subset on any newer server. +""" + +__all__ = ( + "DEFAULT_LIST_VERSION", + "FORMAT_SEPARATOR", + "get_output_format", + "parse_output", +) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index d7012a325..f7331ed84 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,8 +65,8 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'kill_pane', 'kill_window', 'rename_window', - 'select_layout', 'send_keys', 'split_window'] + ['capture_pane', 'kill_pane', 'kill_window', 'list_panes', 'list_sessions', + 'list_windows', 'rename_window', 'select_layout', 'send_keys', 'split_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/execute.py b/src/libtmux/experimental/ops/execute.py index c6b12a345..4197dcfce 100644 --- a/src/libtmux/experimental/ops/execute.py +++ b/src/libtmux/experimental/ops/execute.py @@ -72,6 +72,7 @@ def run( returncode=raw.returncode, stdout=raw.stdout, stderr=raw.stderr, + version=version, ) @@ -112,4 +113,5 @@ async def arun( returncode=raw.returncode, stdout=raw.stdout, stderr=raw.stderr, + version=version, ) diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index f2c9b9c07..701b4e1ed 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -215,7 +215,9 @@ def build_result( stdout, stderr : Sequence[str] Captured output lines. version : str or None - Used only to render *argv* when it is omitted. + The tmux version the output was produced against. Used to render + *argv* when omitted, and passed to :meth:`_make_result` so payload + parsing can match the version-gated render (e.g. a ``-F`` template). Returns ------- @@ -230,6 +232,7 @@ def build_result( returncode, tuple(stdout), tuple(stderr), + version=version, ) def _make_result( @@ -239,8 +242,14 @@ def _make_result( returncode: int, stdout: tuple[str, ...], stderr: tuple[str, ...], + version: str | None = None, ) -> ResultT: - """Construct the result instance; override to populate typed payloads.""" + """Construct the result instance; override to populate typed payloads. + + ``version`` is the tmux version the output was produced against; payload + parsers that depend on a version-gated render (read ops) use it. The base + implementation and simple overrides ignore it. + """ return t.cast( "ResultT", self.result_cls( diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index a929d79ef..c410c1936 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -164,7 +164,7 @@ def list( -------- >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.list(lambda s: s.safety == "readonly")] - ['capture_pane'] + ['capture_pane', 'list_panes', 'list_sessions', 'list_windows'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index b275171b8..9abe49beb 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -19,9 +19,17 @@ import typing as t from dataclasses import dataclass, field +from libtmux.experimental.models.snapshots import ( + PaneSnapshot, + ServerSnapshot, + SessionSnapshot, + WindowSnapshot, +) from libtmux.experimental.ops.exc import TmuxCommandError if t.TYPE_CHECKING: + from collections.abc import Mapping + from typing_extensions import Self from libtmux.experimental.ops._types import Status @@ -195,3 +203,50 @@ class CapturePaneResult(Result): """ lines: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class ListPanesResult(Result): + """Result of a ``list-panes`` operation. + + Stores the parsed per-pane format mappings as JSON-friendly :attr:`rows` + (so the result serializes without snapshot objects), and derives typed + :class:`~..models.snapshots.PaneSnapshot` / :class:`ServerSnapshot` views on + demand. + """ + + rows: tuple[Mapping[str, str], ...] = () + + @property + def panes(self) -> tuple[PaneSnapshot, ...]: + """One typed pane snapshot per row.""" + return tuple(PaneSnapshot.from_format(row) for row in self.rows) + + @property + def server(self) -> ServerSnapshot: + """The full session/window/pane tree built from the rows.""" + return ServerSnapshot.from_pane_rows(self.rows) + + +@dataclass(frozen=True) +class ListWindowsResult(Result): + """Result of a ``list-windows`` operation (typed :attr:`windows`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def windows(self) -> tuple[WindowSnapshot, ...]: + """One typed window snapshot per row.""" + return tuple(WindowSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class ListSessionsResult(Result): + """Result of a ``list-sessions`` operation (typed :attr:`sessions`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def sessions(self) -> tuple[SessionSnapshot, ...]: + """One typed session snapshot per row.""" + return tuple(SessionSnapshot.from_format(row) for row in self.rows) diff --git a/tests/experimental/ops/test_read_ops.py b/tests/experimental/ops/test_read_ops.py new file mode 100644 index 000000000..3a3ebb5eb --- /dev/null +++ b/tests/experimental/ops/test_read_ops.py @@ -0,0 +1,121 @@ +"""Tests for the read-seam list operations.""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + ListPanes, + ListSessions, + ListWindows, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + FORMAT_SEPARATOR, + get_output_format, +) +from libtmux.experimental.ops.results import ListPanesResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_list_panes_template_matches_neo() -> None: + """The op renders the identical -F template neo would build (no drift).""" + _fields, fmt = get_output_format("list-panes", "3.6a") + argv = ListPanes().render(version="3.6a") + assert "-a" in argv + assert fmt in argv # same template, byte for byte + + +def test_list_panes_parses_rows_into_snapshot_tree() -> None: + """A synthesized list-panes output parses into a ServerSnapshot tree.""" + fields, _ = get_output_format("list-panes", DEFAULT_LIST_VERSION) + + def row(**values: str) -> str: + cells = [values.get(name, "") for name in fields] + return FORMAT_SEPARATOR.join(cells) + FORMAT_SEPARATOR + + stdout = ( + row(session_id="$0", session_name="a", window_id="@1", pane_id="%1"), + row(session_id="$0", session_name="a", window_id="@1", pane_id="%2"), + ) + op = ListPanes() + result = op.build_result(returncode=0, stdout=stdout, version=DEFAULT_LIST_VERSION) + + assert isinstance(result, ListPanesResult) + assert [p.pane_id for p in result.panes] == ["%1", "%2"] + assert [s.session_id for s in result.server.sessions] == ["$0"] + assert [p.pane_id for p in result.server.sessions[0].windows[0].panes] == [ + "%1", + "%2", + ] + + +def test_list_result_serialization_round_trip() -> None: + """A list result round-trips via its JSON-friendly rows.""" + fields, _ = get_output_format("list-panes", DEFAULT_LIST_VERSION) + cells = [""] * len(fields) + cells[fields.index("pane_id")] = "%1" + line = FORMAT_SEPARATOR.join(cells) + FORMAT_SEPARATOR + result = ListPanes().build_result( + returncode=0, + stdout=(line,), + version=DEFAULT_LIST_VERSION, + ) + assert result_from_dict(result_to_dict(result)) == result + + +def test_empty_output_yields_empty_views() -> None: + """No panes -> empty rows, empty snapshot, no error.""" + result = run(ListPanes(), ConcreteEngine(), version="3.6a") + assert result.rows == () + assert result.server.sessions == () + assert result.ok + + +def test_list_panes_live(session: Session) -> None: + """Against real tmux, ListPanes builds a tree containing the fixture pane.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + + # No version -> the safe 3.2a-floor template (a field subset valid on any + # supported tmux); core ids (pane_id/session_id) are always present. + result = run(ListPanes(), engine) + + assert result.ok + pane_ids = {p.pane_id for p in result.panes} + active_pane = session.active_pane + assert active_pane is not None + assert active_pane.pane_id in pane_ids + # the snapshot tree includes the fixture session + session_ids = {s.session_id for s in result.server.sessions} + assert session.session_id in session_ids + + +def test_list_sessions_live(session: Session) -> None: + """Against real tmux, ListSessions returns the fixture session.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + result = run(ListSessions(), engine) + assert result.ok + assert session.session_id in {s.session_id for s in result.sessions} + + +def test_list_windows_live(session: Session) -> None: + """Against real tmux, ListWindows returns typed window snapshots.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + result = run(ListWindows(), engine) + assert result.ok + assert all(w.window_id.startswith("@") for w in result.windows) diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index b87261dd7..0d6d4c766 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -45,7 +45,7 @@ def test_list_predicate_filters() -> None: readonly = [ spec.kind for spec in registry.list(lambda spec: spec.safety == "readonly") ] - assert readonly == ["capture_pane"] + assert readonly == ["capture_pane", "list_panes", "list_sessions", "list_windows"] def test_register_duplicate_fails_closed() -> None: From 7f5495f2610fe4764043d161c28baac2fac04421 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:02:28 -0500 Subject: [PATCH 010/223] docs(experimental): Add tmuxop-catalog directive why: The operation catalog is registry-derived data, so rendering it in docs keeps the operation reference from drifting from the code -- and the docs gate then exercises catalog() on every build. what: - docs/_ext/tmuxop.py: an in-repo Sphinx directive `tmuxop-catalog` that walks libtmux.experimental.ops.catalog() and emits a table, with :scope:/:safety:/:primitive-only: filters; warns (not raises) on empty - conf.py: add docs/_ext to sys.path and 'tmuxop' to extra_extensions - docs/experimental.md: an experimental ops/engines overview embedding the catalog (full + readonly + destructive views), in the index toctree --- docs/_ext/tmuxop.py | 120 +++++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 + docs/experimental.md | 37 +++++++++++++ docs/index.md | 1 + 4 files changed, 160 insertions(+) create mode 100644 docs/_ext/tmuxop.py create mode 100644 docs/experimental.md diff --git a/docs/_ext/tmuxop.py b/docs/_ext/tmuxop.py new file mode 100644 index 000000000..550c0bbd8 --- /dev/null +++ b/docs/_ext/tmuxop.py @@ -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, + } diff --git a/docs/conf.py b/docs/conf.py index 379d0b3c6..5efc0c9a9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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] = {} @@ -34,6 +35,7 @@ "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx.ext.todo", + "tmuxop", ], intersphinx_mapping={ "python": ("https://docs.python.org/", None), diff --git a/docs/experimental.md b/docs/experimental.md new file mode 100644 index 000000000..8cebf6145 --- /dev/null +++ b/docs/experimental.md @@ -0,0 +1,37 @@ +(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. + +## 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 +``` diff --git a/docs/index.md b/docs/index.md index 683c17ebc..f6f5c4f48 100644 --- a/docs/index.md +++ b/docs/index.md @@ -125,6 +125,7 @@ api/index api/testing/index internals/index project/index +experimental history migration glossary From 7a78bb0ed76f1df9beea1c1b2136658a38f6ee42 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:10:26 -0500 Subject: [PATCH 011/223] ControlMode(fix): Consume startup ACK, drain unsolicited blocks why: The sync control engine skipped tmux's startup ACK with a fragile one-shot flags==0 heuristic and had no defense against hook-emitted %begin/%end blocks, so a stray block could desync request->result alignment. The async engine already handles this; backport the approach. what: - consume the startup ACK synchronously at connect (_consume_startup), dropping the one-shot _startup_ack_pending heuristic, so the startup block can never be conflated with a command's result block - drain buffered unsolicited blocks before each batch (_drain_unsolicited), so a hook-triggered command's block left over from a prior call is not mis-attributed to the next command - drain notifications during reads to keep the parser buffer bounded - regression test: many sequential commands stay aligned (first result is real; each call drains before reading its own block) A hook firing mid-pipelined-batch still needs per-command number correlation to disambiguate; single-command run() is robust. --- .../experimental/engines/control_mode.py | 55 ++++++++++++++++--- .../contract/test_control_engine.py | 23 ++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 73f069661..57e4f6f88 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -44,6 +44,7 @@ _ERROR_PREFIX = b"%error " _READ_CHUNK = 65536 _DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 _GRACEFUL_EXIT_TIMEOUT = 0.5 _TERMINATE_TIMEOUT = 1.0 _GUARD_MIN_PARTS = 3 @@ -187,7 +188,6 @@ def __init__( self._parser = ControlModeParser() self._proc: subprocess.Popen[bytes] | None = None self._selector: selectors.DefaultSelector | None = None - self._startup_ack_pending = True def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" @@ -200,6 +200,10 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: rendered = [tuple(req.args) for req in requests] with self._lock: self._ensure_started() + # Discard any unsolicited blocks (hook-triggered commands) left + # buffered from earlier activity, so they cannot be mis-attributed + # to this batch's commands. + self._drain_unsolicited() payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) self._write(payload) blocks = self._read_blocks(len(rendered)) @@ -216,7 +220,6 @@ def close(self) -> None: self._proc = None self._selector = None self._parser = ControlModeParser() - self._startup_ack_pending = True if selector is not None: with contextlib.suppress(Exception): selector.close() @@ -279,7 +282,48 @@ def _ensure_started(self) -> None: selector.register(proc.stderr, selectors.EVENT_READ, "stderr") self._proc = proc self._selector = selector - self._startup_ack_pending = True + self._consume_startup() + + def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before any command. + + Consuming it up front (instead of skipping the first block heuristically + at read time) means the startup block can never be conflated with a + command's result block. + """ + deadline = time.monotonic() + _STARTUP_TIMEOUT + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return + if self._proc is not None and self._proc.poll() is not None: + return + self._pump(remaining) + if self._parser.blocks(): # startup ACK seen and discarded + self._parser.notifications() + return + self._parser.notifications() + + def _drain_unsolicited(self) -> None: + """Discard any blocks/notifications already buffered (non-blocking).""" + selector = self._selector + if selector is None: + return + while selector.select(0): + self._pump(0) + self._parser.blocks() + self._parser.notifications() + + def _pump(self, timeout: float) -> None: + """Wait up to *timeout* for output and feed it to the parser.""" + selector = self._selector + if selector is None: + return + for key, _events in selector.select(timeout): + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() def _write(self, payload: bytes) -> None: proc = self._proc @@ -321,13 +365,10 @@ def _read_blocks(self, count: int) -> list[ControlModeBlock]: elif key.data == "stderr": self._read_stderr() for block in self._parser.blocks(): - if self._startup_ack_pending: - self._startup_ack_pending = False - if block.flags == 0: - continue blocks.append(block) if len(blocks) == count: break + self._parser.notifications() # sync engine ignores notifications return blocks def _read_stdout(self) -> None: diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py index 73aa1a6f8..dd280cc98 100644 --- a/tests/experimental/contract/test_control_engine.py +++ b/tests/experimental/contract/test_control_engine.py @@ -27,6 +27,29 @@ from libtmux.session import Session +def test_control_sequential_commands_stay_aligned(session: Session) -> None: + """Many sequential commands keep result alignment (drain-between-calls path). + + The first command must get the real result (not the consumed startup ACK), + and each subsequent call drains any unsolicited blocks before reading its own. + """ + server = session.server + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + + with ControlModeEngine.for_server(server) as engine: + for index in range(5): + result = run( + SendKeys(target=PaneId(pane.pane_id), keys=f"# {index}"), engine + ) + assert result.ok + captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert isinstance(captured, CapturePaneResult) + assert captured.ok + + def test_control_split_creates_real_pane(session: Session) -> None: """A control-mode split returns a typed result whose pane really exists.""" server = session.server From 0a2dd2d37c2483c126e4c37629a8297d8c6b3992 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:19:24 -0500 Subject: [PATCH 012/223] Ops(feat): Add lazy-plan chainability (>> and ; folding) why: The chainable-commands prototype folds independent commands into one "tmux a ; b" dispatch. Our typed-op model is a better host for it -- the Operation already carries a `chainable` classvar and the result Status already reserves `skipped` for exactly the chain-drop case. So yes, lazy mode can adopt the prototype's chainability. what: - mark output/creation ops non-chainable (capture-pane, split-window; list-* already were) so a fold never drops captured data or an id - ops._chain: render_chain (join chainable ops with standalone ';', escaping a trailing-';' arg), ensure_chainable (fail closed), and attribute -- splitting one merged ';'-chain result into a typed result per op (success -> all complete; failure -> first failed, rest skipped, matching tmux cmd-queue.c cmdq_remove_group); plus OpChain with >>/then - Operation.__rshift__/then compose into an OpChain; result_with_status() builds a result with an explicit status (skipped/failed attribution) - LazyPlan.execute/aexecute gain fold=False (opt-in): maximal runs of chainable, resolved ops dispatch once via engine.run; the sans-I/O _drive yields _Single or _Chain so sync and async share the core; add_chain() records an OpChain - tests: >> composition, render_chain, fold=one dispatch, fold-off=N dispatches, failure attribution, creators stay unfolded, add_chain --- src/libtmux/experimental/ops/__init__.py | 2 + src/libtmux/experimental/ops/_chain.py | 138 ++++++++++++++++ .../experimental/ops/_ops/capture_pane.py | 1 + .../experimental/ops/_ops/split_window.py | 1 + src/libtmux/experimental/ops/operation.py | 35 ++++ src/libtmux/experimental/ops/plan.py | 116 +++++++++++-- tests/experimental/ops/test_chain.py | 155 ++++++++++++++++++ 7 files changed, 430 insertions(+), 18 deletions(-) create mode 100644 src/libtmux/experimental/ops/_chain.py create mode 100644 tests/experimental/ops/test_chain.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 1947be8d5..6e6f0df6a 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -19,6 +19,7 @@ from __future__ import annotations +from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._ops import ( CapturePane, KillPane, @@ -102,6 +103,7 @@ "ListWindows", "ListWindowsResult", "NameRef", + "OpChain", "OpSpec", "Operation", "OperationError", diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py new file mode 100644 index 000000000..0d62b12cb --- /dev/null +++ b/src/libtmux/experimental/ops/_chain.py @@ -0,0 +1,138 @@ +r"""Chaining and ``;``-folding for lazy plans. + +A run of *chainable* operations can render to a single ``tmux a \; b`` invocation +and dispatch once, instead of one process fork / control-mode command per +operation. This ports the chainable-commands prototype's fold onto the typed-op +model: only operations whose ``chainable`` class var is ``True`` (no captured +output, no created object) fold; the rest dispatch alone. + +tmux runs a ``;`` sequence up to the first error and drops the remainder +(``cmd-queue.c`` ``cmdq_remove_group``), returning one merged stdout/exit with no +per-command boundary. :func:`attribute` recovers a typed result per operation: +on success every member is ``complete``; on failure the first member is +``failed`` and the rest are ``skipped`` (the status the spine reserves for +exactly this case). +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + from libtmux.experimental.engines.base import CommandResult + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +def ensure_chainable(op: Operation[t.Any]) -> None: + """Raise if *op* cannot be folded into a ``;`` chain (fail closed).""" + if not op.chainable: + msg = ( + f"operation {op.kind!r} is not chainable; it produces output or " + f"creates an object and must dispatch on its own" + ) + raise OperationError(msg) + + +def _escape_arg(token: str) -> str: + r"""Escape a trailing ``;`` so tmux does not read the arg as a separator.""" + if token.endswith(";"): + return token[:-1] + "\\;" + return token + + +def render_chain( + ops: Sequence[Operation[t.Any]], + version: str | None = None, +) -> tuple[str, ...]: + r"""Render chainable ops to one argv with standalone ``;`` separators. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, RenameWindow + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> render_chain([ + ... SendKeys(target=PaneId("%1"), keys="vim", enter=True), + ... RenameWindow(target=WindowId("@1"), name="edit"), + ... ]) + ('send-keys', '-t', '%1', 'vim', 'Enter', ';', 'rename-window', '-t', '@1', 'edit') + """ + out: list[str] = [] + for index, op in enumerate(ops): + if index: + out.append(";") + out.extend(_escape_arg(token) for token in op.render(version=version)) + return tuple(out) + + +def attribute( + ops: Sequence[Operation[t.Any]], + merged: CommandResult, + version: str | None = None, +) -> list[Result]: + """Split one merged ``;``-chain result into a typed result per operation.""" + if merged.returncode == 0 and not merged.stderr: + return [op.result_with_status("complete", version=version) for op in ops] + first, *rest = ops + results: list[Result] = [ + first.result_with_status( + "failed", + version=version, + returncode=merged.returncode, + stdout=tuple(merged.stdout), + stderr=tuple(merged.stderr), + ), + ] + results.extend(op.result_with_status("skipped", version=version) for op in rest) + return results + + +@dataclass(frozen=True) +class OpChain: + """An ordered group of operations composed with :meth:`~.Operation.then`. + + A power-user, inspectable handle for explicit chaining. Add it to a + :class:`~.plan.LazyPlan` with :meth:`~.plan.LazyPlan.add_chain`; the plan's + folded execution (``execute(fold=True)``) batches chainable runs anyway. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, RenameWindow + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> chain = ( + ... SendKeys(target=PaneId("%1"), keys="q") + ... >> RenameWindow(target=WindowId("@1"), name="done") + ... ) + >>> [op.kind for op in chain] + ['send_keys', 'rename_window'] + """ + + ops: tuple[Operation[t.Any], ...] + + def then(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Append an operation or chain.""" + return OpChain((*self.ops, *_as_ops(other))) + + def __rshift__(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Append with ``>>``.""" + return self.then(other) + + def __iter__(self) -> Iterator[Operation[t.Any]]: + """Iterate the operations in order.""" + return iter(self.ops) + + def __len__(self) -> int: + """Return the number of operations in the chain.""" + return len(self.ops) + + +def _as_ops(other: Operation[t.Any] | OpChain) -> tuple[Operation[t.Any], ...]: + """Normalize an operation or chain to a tuple of operations.""" + if isinstance(other, OpChain): + return other.ops + return (other,) diff --git a/src/libtmux/experimental/ops/_ops/capture_pane.py b/src/libtmux/experimental/ops/_ops/capture_pane.py index bd14f345a..684e3c6f6 100644 --- a/src/libtmux/experimental/ops/_ops/capture_pane.py +++ b/src/libtmux/experimental/ops/_ops/capture_pane.py @@ -60,6 +60,7 @@ class CapturePane(Operation[CapturePaneResult]): scope = "pane" result_cls = CapturePaneResult safety = "readonly" + chainable = False # produces stdout that must not be merged into a ; chain effects = Effects(read_only=True, reads_output=True, idempotent=True) flag_version_map: t.ClassVar[Mapping[str, str]] = { "trim_trailing": "3.4", diff --git a/src/libtmux/experimental/ops/_ops/split_window.py b/src/libtmux/experimental/ops/_ops/split_window.py index 3179cedfa..2bf534d21 100644 --- a/src/libtmux/experimental/ops/_ops/split_window.py +++ b/src/libtmux/experimental/ops/_ops/split_window.py @@ -64,6 +64,7 @@ class SplitWindow(Operation[SplitWindowResult]): scope = "window" result_cls = SplitWindowResult safety = "mutating" + chainable = False # captures a new pane id (-P -F); cannot fold into a ; chain effects = Effects(creates="pane") flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index 701b4e1ed..57479717f 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -27,6 +27,7 @@ if t.TYPE_CHECKING: from collections.abc import Mapping, Sequence + from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._types import ( Effects, Safety, @@ -235,6 +236,40 @@ def build_result( version=version, ) + def result_with_status( + self, + status: Status, + *, + version: str | None = None, + returncode: int = 0, + stdout: Sequence[str] = (), + stderr: Sequence[str] = (), + ) -> ResultT: + """Build a result with an explicit *status* (no status inference). + + Used when the status is known out of band -- e.g. a chained operation + whose ``;`` group ran but whose own outcome must be marked ``skipped`` + because an earlier member failed. + """ + return self._make_result( + self.render(version=version), + status, + returncode, + tuple(stdout), + tuple(stderr), + version=version, + ) + + def then(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Compose with another operation (or chain) into an :class:`OpChain`.""" + from libtmux.experimental.ops._chain import OpChain + + return OpChain((self,)).then(other) + + def __rshift__(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Compose operations with ``>>`` into an :class:`OpChain`.""" + return self.then(other) + def _make_result( self, argv: tuple[str, ...], diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 50d07d10a..e83449cff 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -18,6 +18,8 @@ import typing as t from dataclasses import dataclass, field +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.ops._chain import attribute, render_chain from libtmux.experimental.ops._types import ( PaneId, SessionId, @@ -34,12 +36,31 @@ from typing_extensions import Self - from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.engines.base import ( + AsyncTmuxEngine, + CommandResult, + TmuxEngine, + ) + from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._types import Target from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.results import Result +@dataclass(frozen=True) +class _Single: + """Drive request: run one resolved operation and return its typed result.""" + + op: Operation[t.Any] + + +@dataclass(frozen=True) +class _Chain: + """Drive request: dispatch a folded ``;`` chain and return the merged result.""" + + argv: tuple[str, ...] + + def _target_from_id(value: str) -> Target: """Map a captured concrete id back to its typed target.""" if value.startswith("%"): @@ -154,47 +175,106 @@ def from_list(cls, data: t.Sequence[t.Mapping[str, t.Any]]) -> LazyPlan: plan._operations = [operation_from_dict(item) for item in data] return plan + def add_chain(self, chain: OpChain) -> None: + """Record every operation of an :class:`~._chain.OpChain` in order.""" + self._operations.extend(chain.ops) + def _drive( self, version: str | None, - ) -> Generator[Operation[t.Any], Result, PlanResult]: - """Sans-I/O resolution core: yield a resolved op, resume with its result.""" + fold: bool, + ) -> Generator[_Single | _Chain, t.Any, PlanResult]: + """Sans-I/O resolution core. + + Yields a :class:`_Single` (driver runs one op and returns its + :class:`~.results.Result`) or, when ``fold`` is set, a :class:`_Chain` + for a maximal run of chainable ops (driver returns the merged + :class:`~..engines.base.CommandResult`, attributed per op here). The + sync and async drivers differ only in ``run`` vs ``await arun`` and + ``engine.run`` vs ``await engine.run``. + """ bindings: dict[int, str] = {} - results: list[Result] = [] - for index, operation in enumerate(self._operations): - result = yield _resolve(operation, bindings) - results.append(result) - created = getattr(result, "new_pane_id", None) - if created is not None: - bindings[index] = created - return PlanResult(tuple(results), bindings) + results: dict[int, Result] = {} + index = 0 + total = len(self._operations) + while index < total: + operation = self._operations[index] + if fold and operation.chainable: + indices: list[int] = [] + group: list[Operation[t.Any]] = [] + cursor = index + while cursor < total and self._operations[cursor].chainable: + indices.append(cursor) + group.append(_resolve(self._operations[cursor], bindings)) + cursor += 1 + if len(group) == 1: + results[indices[0]] = yield _Single(group[0]) + else: + merged: CommandResult = yield _Chain(render_chain(group, version)) + results.update( + zip(indices, attribute(group, merged, version), strict=True), + ) + index = cursor + else: + result = yield _Single(_resolve(operation, bindings)) + results[index] = result + created = getattr(result, "new_pane_id", None) + if created is not None: + bindings[index] = created + index += 1 + ordered = tuple(results[slot] for slot in range(total)) + return PlanResult(ordered, bindings) def execute( self, engine: TmuxEngine, *, version: str | None = None, + fold: bool = False, ) -> PlanResult: - """Resolve and execute the plan synchronously.""" - gen = self._drive(version) + """Resolve and execute the plan synchronously. + + With ``fold=True``, maximal runs of chainable operations dispatch as one + ``tmux a ; b`` invocation (fewer forks / one control-mode command), + attributing a typed result per operation. ``fold`` defaults off because + folding changes observable semantics (fewer dispatches; a folded + failure marks the first member failed and the rest skipped). + """ + gen = self._drive(version, fold) try: - operation = next(gen) + request = next(gen) while True: - operation = gen.send(run(operation, engine, version=version)) + request = gen.send(self._dispatch(request, engine, version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) + def _dispatch( + self, + request: _Single | _Chain, + engine: TmuxEngine, + version: str | None, + ) -> t.Any: + """Run one drive request synchronously.""" + if isinstance(request, _Chain): + return engine.run(CommandRequest.from_args(*request.argv)) + return run(request.op, engine, version=version) + async def aexecute( self, engine: AsyncTmuxEngine, *, version: str | None = None, + fold: bool = False, ) -> PlanResult: """Resolve and execute the plan asynchronously (same resolution core).""" - gen = self._drive(version) + gen = self._drive(version, fold) try: - operation = next(gen) + request = next(gen) while True: - operation = gen.send(await arun(operation, engine, version=version)) + if isinstance(request, _Chain): + raw = await engine.run(CommandRequest.from_args(*request.argv)) + request = gen.send(raw) + else: + request = gen.send(await arun(request.op, engine, version=version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py new file mode 100644 index 000000000..635713da2 --- /dev/null +++ b/tests/experimental/ops/test_chain.py @@ -0,0 +1,155 @@ +"""Tests for lazy-plan chainability (>> composition and ; folding).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import CommandResult +from libtmux.experimental.ops import ( + CapturePane, + KillWindow, + LazyPlan, + OpChain, + RenameWindow, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._chain import ensure_chainable, render_chain +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class _CountingEngine: + """An engine that counts dispatches and returns a canned result.""" + + def __init__(self, *, returncode: int = 0, stderr: tuple[str, ...] = ()) -> None: + self.returncode = returncode + self.stderr = stderr + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and return the canned result.""" + self.calls.append(request.args) + return CommandResult( + cmd=("tmux", *request.args), + stderr=self.stderr, + returncode=self.returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def test_rshift_builds_opchain() -> None: + """``>>`` composes operations into an ordered OpChain.""" + chain = SendKeys(target=PaneId("%1"), keys="q") >> RenameWindow( + target=WindowId("@1"), + name="done", + ) + assert isinstance(chain, OpChain) + assert [op.kind for op in chain] == ["send_keys", "rename_window"] + + +def test_ensure_chainable_rejects_output_ops() -> None: + """Output/creation ops are not chainable (fail closed).""" + ensure_chainable(SendKeys(target=PaneId("%1"), keys="q")) # ok + with pytest.raises(OperationError, match="not chainable"): + ensure_chainable(CapturePane(target=PaneId("%1"))) + with pytest.raises(OperationError, match="not chainable"): + ensure_chainable(SplitWindow(target=WindowId("@1"))) + + +def test_render_chain_joins_with_separator() -> None: + """Chainable ops render to one argv with standalone ';' separators.""" + argv = render_chain( + [ + SendKeys(target=PaneId("%1"), keys="vim", enter=True), + RenameWindow(target=WindowId("@1"), name="edit"), + ], + ) + assert argv == ( + "send-keys", + "-t", + "%1", + "vim", + "Enter", + ";", + "rename-window", + "-t", + "@1", + "edit", + ) + + +def test_fold_dispatches_once() -> None: + """A run of chainable ops folds into a single engine dispatch.""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + plan.add(KillWindow(target=WindowId("@2"))) + engine = _CountingEngine() + + outcome = plan.execute(engine, fold=True) + + assert len(engine.calls) == 1 # all three folded into one ';' dispatch + assert ";" in engine.calls[0] + assert outcome.ok + assert [r.status for r in outcome.results] == ["complete", "complete", "complete"] + + +def test_no_fold_dispatches_per_op() -> None: + """Without folding, each op dispatches on its own (default behaviour).""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + engine = _CountingEngine() + + plan.execute(engine) # fold defaults to False + + assert len(engine.calls) == 2 + + +def test_fold_failure_attributes_first_failed_rest_skipped() -> None: + """A folded failure marks the first op failed and the rest skipped.""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + plan.add(KillWindow(target=WindowId("@2"))) + engine = _CountingEngine(returncode=1, stderr=("boom",)) + + outcome = plan.execute(engine, fold=True) + + assert [r.status for r in outcome.results] == ["failed", "skipped", "skipped"] + assert not outcome.ok + + +def test_fold_keeps_creation_ops_unfolded() -> None: + """A non-chainable creator dispatches alone; chainable neighbours fold.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) # not chainable + plan.add(SendKeys(target=pane, keys="vim")) # chainable, targets new pane + plan.add(RenameWindow(target=WindowId("@1"), name="x")) # chainable + from libtmux.experimental.engines import ConcreteEngine + + outcome = plan.execute(ConcreteEngine(), fold=True) + + # split resolved the pane id; the send-keys folded with rename, retargeted + assert outcome.results[1].argv[:3] == ("send-keys", "-t", "%1") + assert outcome.ok + + +def test_add_chain() -> None: + """A composed OpChain can be added to a plan in order.""" + plan = LazyPlan() + plan.add_chain( + SendKeys(target=PaneId("%1"), keys="q") >> KillWindow(target=WindowId("@1")), + ) + assert [op.kind for op in plan] == ["send_keys", "kill_window"] From ba1c0dd7efec46c30ca23391c9d84f5e033fad55 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:28:29 -0500 Subject: [PATCH 013/223] Facade(feat): Add Server/Session/Window facades + creation ops why: Extend the mode-in-the-type facades beyond the pane seed so a typed return value distinguishes eager/lazy/async across scopes -- and add the few creation ops the cross-scope navigation needs. what: - ops: NewWindow / NewSession (CreateResult, capture the new id), KillSession, RenameSession; generalize binding capture via Result.created_id (base None; SplitWindowResult -> new_pane_id; CreateResult -> new_id) so lazy plans bind window/session creations too - facade: eager Server -> Session -> Window -> Pane navigation (EagerServer/EagerSession/EagerWindow); LazyWindow (records into a plan); AsyncPane / AsyncWindow (await arun) -- all over the same ops. Control mode stays an engine choice, not a separate facade family - EagerServer.for_server() binds the classic engine to a live Server - tests: offline navigation across scopes/modes (concrete engine), and a live eager Server -> Session -> Window -> Pane build against real tmux with cleanup --- src/libtmux/experimental/facade/__init__.py | 34 ++- src/libtmux/experimental/facade/pane.py | 71 ++++++- src/libtmux/experimental/facade/server.py | 56 +++++ src/libtmux/experimental/facade/session.py | 75 +++++++ src/libtmux/experimental/facade/window.py | 197 ++++++++++++++++++ src/libtmux/experimental/ops/__init__.py | 10 + src/libtmux/experimental/ops/_ops/__init__.py | 8 + .../experimental/ops/_ops/kill_session.py | 34 +++ .../experimental/ops/_ops/new_session.py | 84 ++++++++ .../experimental/ops/_ops/new_window.py | 89 ++++++++ .../experimental/ops/_ops/rename_session.py | 36 ++++ src/libtmux/experimental/ops/catalog.py | 6 +- src/libtmux/experimental/ops/plan.py | 5 +- src/libtmux/experimental/ops/results.py | 30 +++ .../experimental/facade/test_facade_matrix.py | 90 ++++++++ 15 files changed, 813 insertions(+), 12 deletions(-) create mode 100644 src/libtmux/experimental/facade/server.py create mode 100644 src/libtmux/experimental/facade/session.py create mode 100644 src/libtmux/experimental/facade/window.py create mode 100644 src/libtmux/experimental/ops/_ops/kill_session.py create mode 100644 src/libtmux/experimental/ops/_ops/new_session.py create mode 100644 src/libtmux/experimental/ops/_ops/new_window.py create mode 100644 src/libtmux/experimental/ops/_ops/rename_session.py create mode 100644 tests/experimental/facade/test_facade_matrix.py diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/facade/__init__.py index 965b8f4da..c034c34a7 100644 --- a/src/libtmux/experimental/facade/__init__.py +++ b/src/libtmux/experimental/facade/__init__.py @@ -1,17 +1,39 @@ """Engine-typed facades over the operation spine. -The execution mode lives in the facade *type* (eager vs lazy vs async vs -control), so each method has one statically-known return type, while the -operation definitions stay shared. This package currently ships the pane-scope -seed (:class:`EagerPane`, :class:`LazyPane`); the full Server/Session/Window/ -Pane/Client matrix is described in issue 689. +The execution mode lives in the facade *type* (eager vs lazy vs async), so each +method has one statically-known return type, while the operation definitions stay +shared. The facades form a small matrix over scope x mode: + +========== =========== ========== =========== +scope eager lazy async +========== =========== ========== =========== +server EagerServer -- -- +session EagerSession -- -- +window EagerWindow LazyWindow AsyncWindow +pane EagerPane LazyPane AsyncPane +========== =========== ========== =========== + +Eager handles execute immediately and return live handles; lazy handles record +into a :class:`~..ops.plan.LazyPlan`; async handles await an +:class:`~..engines.base.AsyncTmuxEngine`. "Control mode" is not a separate family +-- any eager/async facade bound to a ``ControlModeEngine`` already uses it. See +issue 689 for the full matrix. """ from __future__ import annotations -from libtmux.experimental.facade.pane import EagerPane, LazyPane +from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.facade.server import EagerServer +from libtmux.experimental.facade.session import EagerSession +from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow __all__ = ( + "AsyncPane", + "AsyncWindow", "EagerPane", + "EagerServer", + "EagerSession", + "EagerWindow", "LazyPane", + "LazyWindow", ) diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/facade/pane.py index 47e803791..61aec8d23 100644 --- a/src/libtmux/experimental/facade/pane.py +++ b/src/libtmux/experimental/facade/pane.py @@ -25,12 +25,13 @@ CapturePane, SendKeys, SplitWindow, + arun, run, ) from libtmux.experimental.ops._types import PaneId if t.TYPE_CHECKING: - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops._types import Target from libtmux.experimental.ops.plan import LazyPlan from libtmux.experimental.ops.results import CapturePaneResult, Result @@ -163,3 +164,71 @@ def capture(self, *, start: int | None = None, end: int | None = None) -> LazyPa """Record a capture against this handle; return self for chaining.""" self.plan.add(CapturePane(target=self.ref, start=start, end=end)) return self + + +@dataclass(frozen=True) +class AsyncPane: + """An async live pane handle: the eager pane, awaited. + + Identical in shape to :class:`EagerPane` -- same operations, same spine -- + but bound to an :class:`~..engines.base.AsyncTmuxEngine` and awaited. This is + why async is a sibling facade, not a transformation. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> async def main(): + ... pane = AsyncPane(AsyncConcreteEngine(), "%0") + ... child = await pane.split(horizontal=True) + ... return child.pane_id + >>> asyncio.run(main()) + '%1' + """ + + engine: AsyncTmuxEngine + pane_id: str + version: str | None = None + + async def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> AsyncPane: + """Split this pane and return a live async handle to the new pane.""" + result = await arun( + SplitWindow( + target=PaneId(self.pane_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + + async def send_keys(self, keys: str, *, enter: bool = False) -> Result: + """Send keys to this pane; return the typed result.""" + return await arun( + SendKeys(target=PaneId(self.pane_id), keys=keys, enter=enter), + self.engine, + version=self.version, + ) + + async def capture( + self, + *, + start: int | None = None, + end: int | None = None, + ) -> CapturePaneResult: + """Capture this pane's contents; return the typed result.""" + return await arun( + CapturePane(target=PaneId(self.pane_id), start=start, end=end), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/server.py b/src/libtmux/experimental/facade/server.py new file mode 100644 index 000000000..caf8de7a9 --- /dev/null +++ b/src/libtmux/experimental/facade/server.py @@ -0,0 +1,56 @@ +"""Server-scope eager facade -- the entry point for live navigation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.session import EagerSession +from libtmux.experimental.ops import NewSession, run + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + + +@dataclass(frozen=True) +class EagerServer: + """A live server handle; the root of eager facade navigation. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> server = EagerServer(ConcreteEngine()) + >>> session = server.new_session(name="work") + >>> session.session_id + '$1' + >>> window = session.new_window() + >>> pane = window.split() + >>> pane.pane_id + '%1' + """ + + engine: TmuxEngine + version: str | None = None + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> EagerSession: + """Create a detached session; return a live session handle.""" + result = run( + NewSession(session_name=name, start_directory=start_directory), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return EagerSession(self.engine, result.new_id, self.version) + + @classmethod + def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer: + """Bind an eager facade to a live :class:`libtmux.Server`'s classic engine.""" + from libtmux.experimental.engines import SubprocessEngine + + return cls(SubprocessEngine.for_server(server), version=version) diff --git a/src/libtmux/experimental/facade/session.py b/src/libtmux/experimental/facade/session.py new file mode 100644 index 000000000..c2cad8c57 --- /dev/null +++ b/src/libtmux/experimental/facade/session.py @@ -0,0 +1,75 @@ +"""Session-scope eager facade over the operation spine.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.window import EagerWindow +from libtmux.experimental.ops import ( + KillSession, + NewWindow, + RenameSession, + run, +) +from libtmux.experimental.ops._types import SessionId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerSession: + """A live session handle; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> session = EagerSession(ConcreteEngine(), "$0") + >>> window = session.new_window(name="build") + >>> window.window_id + '@1' + >>> session.rename("work").ok + True + """ + + engine: TmuxEngine + session_id: str + version: str | None = None + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> EagerWindow: + """Create a window in this session; return a live window handle.""" + result = run( + NewWindow( + target=SessionId(self.session_id), + name=name, + start_directory=start_directory, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return EagerWindow(self.engine, result.new_id, self.version) + + def rename(self, name: str) -> Result: + """Rename this session.""" + return run( + RenameSession(target=SessionId(self.session_id), name=name), + self.engine, + version=self.version, + ) + + def kill(self) -> Result: + """Kill this session.""" + return run( + KillSession(target=SessionId(self.session_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/window.py b/src/libtmux/experimental/facade/window.py new file mode 100644 index 000000000..32e4ed93a --- /dev/null +++ b/src/libtmux/experimental/facade/window.py @@ -0,0 +1,197 @@ +"""Window-scope facades (eager / lazy / async) over the operation spine. + +Mirrors the pane facades one scope up: an :class:`EagerWindow` executes now and +returns live handles (``split()`` -> :class:`~.pane.EagerPane`), a +:class:`LazyWindow` records into a plan, and an :class:`AsyncWindow` awaits. All +three drive the *same* window-scope operations; only the facade differs. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.ops import ( + KillWindow, + RenameWindow, + SelectLayout, + SplitWindow, + arun, + run, +) +from libtmux.experimental.ops._types import WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerWindow: + """A live window handle bound to an engine; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> window = EagerWindow(ConcreteEngine(), "@1") + >>> pane = window.split(horizontal=True) + >>> pane.pane_id + '%1' + >>> window.rename("build").ok + True + """ + + engine: TmuxEngine + window_id: str + version: str | None = None + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> EagerPane: + """Split this window's active pane; return a live pane handle.""" + result = run( + SplitWindow( + target=WindowId(self.window_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + + def rename(self, name: str) -> Result: + """Rename this window.""" + return run( + RenameWindow(target=WindowId(self.window_id), name=name), + self.engine, + version=self.version, + ) + + def select_layout(self, layout: str) -> Result: + """Apply a layout to this window.""" + return run( + SelectLayout(target=WindowId(self.window_id), layout=layout), + self.engine, + version=self.version, + ) + + def kill(self) -> Result: + """Kill this window.""" + return run( + KillWindow(target=WindowId(self.window_id)), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyWindow: + """A deferred window handle; methods record into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> window = LazyWindow(plan, WindowId("@1")) + >>> pane = window.split() + >>> _ = window.rename("build") + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.ok + True + """ + + plan: LazyPlan + ref: Target + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> LazyPane: + """Record a split; return a deferred pane handle to the new pane.""" + slot = self.plan.add( + SplitWindow( + target=self.ref, + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + ) + return LazyPane(self.plan, slot) + + def rename(self, name: str) -> LazyWindow: + """Record a rename; return self for chaining.""" + self.plan.add(RenameWindow(target=self.ref, name=name)) + return self + + def select_layout(self, layout: str) -> LazyWindow: + """Record a layout change; return self for chaining.""" + self.plan.add(SelectLayout(target=self.ref, layout=layout)) + return self + + def kill(self) -> LazyWindow: + """Record a kill; return self for chaining.""" + self.plan.add(KillWindow(target=self.ref)) + return self + + +@dataclass(frozen=True) +class AsyncWindow: + """An async live window handle: the eager window, awaited.""" + + engine: AsyncTmuxEngine + window_id: str + version: str | None = None + + async def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> AsyncPane: + """Split this window's active pane; return a live async pane handle.""" + result = await arun( + SplitWindow( + target=WindowId(self.window_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + + async def rename(self, name: str) -> Result: + """Rename this window.""" + return await arun( + RenameWindow(target=WindowId(self.window_id), name=name), + self.engine, + version=self.version, + ) + + async def kill(self) -> Result: + """Kill this window.""" + return await arun( + KillWindow(target=WindowId(self.window_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 6e6f0df6a..75c986674 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -23,10 +23,14 @@ from libtmux.experimental.ops._ops import ( CapturePane, KillPane, + KillSession, KillWindow, ListPanes, ListSessions, ListWindows, + NewSession, + NewWindow, + RenameSession, RenameWindow, SelectLayout, SendKeys, @@ -68,6 +72,7 @@ from libtmux.experimental.ops.results import ( AckResult, CapturePaneResult, + CreateResult, ListPanesResult, ListSessionsResult, ListWindowsResult, @@ -90,10 +95,12 @@ "CapturePaneResult", "CatalogEntry", "ClientName", + "CreateResult", "DuplicateOperation", "Effects", "IndexRef", "KillPane", + "KillSession", "KillWindow", "LazyPlan", "ListPanes", @@ -103,6 +110,8 @@ "ListWindows", "ListWindowsResult", "NameRef", + "NewSession", + "NewWindow", "OpChain", "OpSpec", "Operation", @@ -110,6 +119,7 @@ "OperationRegistry", "PaneId", "PlanResult", + "RenameSession", "RenameWindow", "Result", "Safety", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index f0449faa4..101f22aa5 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -9,10 +9,14 @@ from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.kill_pane import KillPane +from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows +from libtmux.experimental.ops._ops.new_session import NewSession +from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys @@ -21,10 +25,14 @@ __all__ = ( "CapturePane", "KillPane", + "KillSession", "KillWindow", "ListPanes", "ListSessions", "ListWindows", + "NewSession", + "NewWindow", + "RenameSession", "RenameWindow", "SelectLayout", "SendKeys", diff --git a/src/libtmux/experimental/ops/_ops/kill_session.py b/src/libtmux/experimental/ops/_ops/kill_session.py new file mode 100644 index 000000000..a141de693 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_session.py @@ -0,0 +1,34 @@ +"""The ``kill-session`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillSession(Operation[AckResult]): + """Kill a session. Destructive; produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> KillSession(target=SessionId("$0")).render() + ('kill-session', '-t', '$0') + """ + + kind = "kill_session" + command = "kill-session" + scope = "session" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/new_session.py b/src/libtmux/experimental/ops/_ops/new_session.py new file mode 100644 index 000000000..74925d91b --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_session.py @@ -0,0 +1,84 @@ +"""The ``new-session`` operation (creates a session, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewSession(Operation[CreateResult]): + """Create a detached session; capture the new session's id. + + Examples + -------- + >>> NewSession(session_name="work").render() + ('new-session', '-d', '-s', 'work', '-P', '-F', '#{session_id}') + >>> NewSession().build_result(returncode=0, stdout=("$2",)).new_id + '$2' + """ + + kind = "new_session" + command = "new-session" + scope = "server" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="session") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + session_name: str | None = None + start_directory: str | None = None + environment: Mapping[str, str] | None = None + width: int | None = None + height: int | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-session`` flags (always detached for headless use).""" + out: list[str] = ["-d"] + if self.session_name is not None: + out.extend(("-s", self.session_name)) + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.width is not None: + out.extend(("-x", str(self.width))) + if self.height is not None: + out.extend(("-y", str(self.height))) + if self.capture: + out.extend(("-P", "-F", "#{session_id}")) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured new-session id.""" + new_id = stdout[0].strip() if status == "complete" and stdout else None + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=new_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/new_window.py b/src/libtmux/experimental/ops/_ops/new_window.py new file mode 100644 index 000000000..73091a437 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_window.py @@ -0,0 +1,89 @@ +"""The ``new-window`` operation (creates a window, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewWindow(Operation[CreateResult]): + """Create a window in a session; capture the new window's id. + + ``target`` is the session the window is created in. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> NewWindow(target=SessionId("$0"), name="build").render() + ('new-window', '-t', '$0', '-d', '-n', 'build', '-P', '-F', '#{window_id}') + >>> NewWindow(target=SessionId("$0")).build_result( + ... returncode=0, stdout=("@5",) + ... ).new_id + '@5' + """ + + kind = "new_window" + command = "new-window" + scope = "session" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="window") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + name: str | None = None + start_directory: str | None = None + environment: Mapping[str, str] | None = None + detach: bool = True + capture: bool = True + window_shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-window`` flags.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.name is not None: + out.extend(("-n", self.name)) + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.capture: + out.extend(("-P", "-F", "#{window_id}")) + if self.window_shell is not None: + out.append(self.window_shell) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured new-window id.""" + new_id = stdout[0].strip() if status == "complete" and stdout else None + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=new_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/rename_session.py b/src/libtmux/experimental/ops/_ops/rename_session.py new file mode 100644 index 000000000..937a878e6 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rename_session.py @@ -0,0 +1,36 @@ +"""The ``rename-session`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RenameSession(Operation[AckResult]): + """Rename a session. Produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> RenameSession(target=SessionId("$0"), name="work").render() + ('rename-session', '-t', '$0', 'work') + """ + + kind = "rename_session" + command = "rename-session" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + name: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the new session name.""" + return (self.name,) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index f7331ed84..b12f8d363 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,8 +65,10 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'kill_pane', 'kill_window', 'list_panes', 'list_sessions', - 'list_windows', 'rename_window', 'select_layout', 'send_keys', 'split_window'] + ['capture_pane', 'kill_pane', 'kill_session', 'kill_window', 'list_panes', + 'list_sessions', 'list_windows', 'new_session', 'new_window', + 'rename_session', 'rename_window', 'select_layout', 'send_keys', + 'split_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index e83449cff..d408dbe13 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -218,9 +218,8 @@ def _drive( else: result = yield _Single(_resolve(operation, bindings)) results[index] = result - created = getattr(result, "new_pane_id", None) - if created is not None: - bindings[index] = created + if result.created_id is not None: + bindings[index] = result.created_id index += 1 ordered = tuple(results[slot] for slot in range(total)) return PlanResult(ordered, bindings) diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 9abe49beb..adddb3a18 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -131,6 +131,15 @@ def failed(self) -> bool: """Whether the operation ran and tmux reported failure.""" return self.status == "failed" + @property + def created_id(self) -> str | None: + """The id of an object this operation created, if any (else ``None``). + + Result subclasses for creation ops override this; a lazy plan reads it to + bind a forward :class:`~._types.SlotRef`. The base result creates nothing. + """ + return None + def raise_for_status(self) -> Self: """Raise :class:`~.exc.TmuxCommandError` if the result is not OK. @@ -193,6 +202,27 @@ class SplitWindowResult(Result): new_pane_id: str | None = None + @property + def created_id(self) -> str | None: + """The new pane's id.""" + return self.new_pane_id + + +@dataclass(frozen=True) +class CreateResult(Result): + """Result of an operation that creates an object and captures its id. + + Shared by ``new-window`` / ``new-session`` (and other ``-P -F``-capturing + creators); :attr:`new_id` holds the created object's id (``@N``/``$N``). + """ + + new_id: str | None = None + + @property + def created_id(self) -> str | None: + """The created object's id.""" + return self.new_id + @dataclass(frozen=True) class CapturePaneResult(Result): diff --git a/tests/experimental/facade/test_facade_matrix.py b/tests/experimental/facade/test_facade_matrix.py new file mode 100644 index 000000000..0e37df75f --- /dev/null +++ b/tests/experimental/facade/test_facade_matrix.py @@ -0,0 +1,90 @@ +"""Tests for the facade matrix (scope x mode) over the shared spine.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.facade import ( + AsyncPane, + AsyncWindow, + EagerPane, + EagerServer, + EagerWindow, + LazyWindow, +) +from libtmux.experimental.ops import LazyPlan +from libtmux.experimental.ops._types import WindowId + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_eager_full_navigation_offline() -> None: + """Eager Server->Session->Window->Pane navigation via the concrete engine.""" + server = EagerServer(ConcreteEngine()) + session = server.new_session(name="work") + assert session.session_id == "$1" + window = session.new_window(name="build") + assert window.window_id == "@1" + pane = window.split(horizontal=True) + assert isinstance(pane, EagerPane) + assert pane.pane_id == "%1" + + +def test_eager_window_methods() -> None: + """EagerWindow rename/select_layout/kill return successful results.""" + window = EagerWindow(ConcreteEngine(), "@1") + assert window.rename("x").ok + assert window.select_layout("tiled").ok + assert window.kill().ok + + +def test_lazy_window_records_and_executes() -> None: + """LazyWindow records ops and resolves the new pane on execute.""" + plan = LazyPlan() + window = LazyWindow(plan, WindowId("@1")) + window.split() + window.rename("build") + assert len(plan) == 2 + + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert outcome.results[0].created_id == "%1" + + +def test_async_window_and_pane() -> None: + """Async facades mirror the eager ones via await.""" + + async def main() -> tuple[str, bool]: + window = AsyncWindow(AsyncConcreteEngine(), "@1") + pane = await window.split() + assert isinstance(pane, AsyncPane) + sent = await pane.send_keys("echo hi", enter=True) + return pane.pane_id, sent.ok + + pane_id, ok = asyncio.run(main()) + assert pane_id == "%1" + assert ok + + +def test_eager_navigation_live(session: Session) -> None: + """Eager facade builds a real session/window/pane against tmux, then cleans up.""" + server = session.server + facade = EagerServer.for_server(server) + + created = facade.new_session(name="facade-matrix-test") + try: + assert created.session_id.startswith("$") + assert server.sessions.get(session_id=created.session_id) is not None + + window = created.new_window(name="built") + assert window.window_id.startswith("@") + assert server.windows.get(window_id=window.window_id) is not None + + pane = window.split(horizontal=True) + assert pane.pane_id.startswith("%") + assert server.panes.get(pane_id=pane.pane_id) is not None + finally: + created.kill() From df893a7b22bee9176a8216c8b836e95607836128 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:39:21 -0500 Subject: [PATCH 014/223] Imsg(feat): Add native imsg engine + live parity test why: The native binary peer-protocol engine is the strongest proof the operation/result contract is transport-agnostic -- the same typed CommandResult whether produced by a subprocess, tmux -C, or by speaking tmux's imsg protocol directly. Research confirmed it is pure-stdlib and CI-verifiable; the prototype it is ported from only ever tested against a fake socketpair server, never real tmux. what: - port engines/imsg/{types,v8,base}.py from libtmux-protocol-engines: ImsgEngine over AF_UNIX + sendmsg/recvmsg + SCM_RIGHTS fd-passing, and ProtocolV8Codec (=IIII header, IMSG_FD_MARK high bit of len, peerid=PROTOCOL_VERSION 8, IDENTIFY -> COMMAND -> WRITE_* -> EXIT handshake); posix_spawn local fallback for attach / start-server / no-server-running - adapt to the experimental tuple CommandResult (drop the process field); add imsg.exc (ImsgError / ImsgProtocolError / UnsupportedProtocolVersion) and select the v8 codec directly; keep the version-mismatch retry - register as the opt-in "imsg" engine; import-safe everywhere (AF_UNIX is only touched at runtime; tests skip without it) - tests: v8 codec round-trip + MSG_COMMAND framing (no tmux), plus the live parity test the prototype lacked -- ImsgEngine vs SubprocessEngine return identical stdout/returncode for read-only commands against a real tmux server (runs across the CI tmux matrix) --- src/libtmux/experimental/engines/__init__.py | 2 + .../experimental/engines/imsg/__init__.py | 29 + src/libtmux/experimental/engines/imsg/base.py | 884 ++++++++++++++++++ src/libtmux/experimental/engines/imsg/exc.py | 22 + .../experimental/engines/imsg/types.py | 28 + src/libtmux/experimental/engines/imsg/v8.py | 543 +++++++++++ tests/experimental/engines/__init__.py | 3 + tests/experimental/engines/test_imsg.py | 93 ++ 8 files changed, 1604 insertions(+) create mode 100644 src/libtmux/experimental/engines/imsg/__init__.py create mode 100644 src/libtmux/experimental/engines/imsg/base.py create mode 100644 src/libtmux/experimental/engines/imsg/exc.py create mode 100644 src/libtmux/experimental/engines/imsg/types.py create mode 100644 src/libtmux/experimental/engines/imsg/v8.py create mode 100644 tests/experimental/engines/__init__.py create mode 100644 tests/experimental/engines/test_imsg.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 6738d4716..9b756af4c 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -31,6 +31,7 @@ ControlModeError, ControlModeParser, ) +from libtmux.experimental.engines.imsg import ImsgEngine from libtmux.experimental.engines.registry import ( available_engines, create_engine, @@ -52,6 +53,7 @@ "ControlNotification", "EngineKind", "EngineSpec", + "ImsgEngine", "SubprocessEngine", "TmuxEngine", "available_engines", diff --git a/src/libtmux/experimental/engines/imsg/__init__.py b/src/libtmux/experimental/engines/imsg/__init__.py new file mode 100644 index 000000000..ee0426b94 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/__init__.py @@ -0,0 +1,29 @@ +"""Experimental native imsg engine -- an opt-in easter egg. + +Speaks tmux's binary peer protocol (imsg over the server's ``AF_UNIX`` socket) +directly, with no tmux CLI fork per command. It is the strongest proof that the +operation/result contract is transport-agnostic: it returns the *same* +:class:`~..base.CommandResult` as the subprocess and control-mode engines. + +Caveats (why it is opt-in and not the default): it depends on tmux's *internal* +protocol (``PROTOCOL_VERSION`` 8 only; upstream may bump it), it is POSIX-only +(``AF_UNIX`` + ``SCM_RIGHTS`` fd-passing), and it cannot host ``attach-session`` +(which falls back to a local spawn). Importing this triggers registration under +the ``imsg`` engine name. +""" + +from __future__ import annotations + +from libtmux.experimental.engines.imsg.base import ImsgEngine +from libtmux.experimental.engines.imsg.exc import ( + ImsgError, + ImsgProtocolError, + UnsupportedProtocolVersion, +) + +__all__ = ( + "ImsgEngine", + "ImsgError", + "ImsgProtocolError", + "UnsupportedProtocolVersion", +) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py new file mode 100644 index 000000000..00e56a9a8 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -0,0 +1,884 @@ +"""Shared primitives for tmux imsg protocol engines.""" + +from __future__ import annotations + +import array +import contextlib +import errno +import logging +import os +import pathlib +import selectors +import shutil +import socket +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandRequest, CommandResult +from libtmux.experimental.engines.imsg.exc import ( + ImsgProtocolError, + UnsupportedProtocolVersion, +) +from libtmux.experimental.engines.imsg.types import ImsgFrame, ImsgHeader +from libtmux.experimental.engines.imsg.v8 import ProtocolV8Codec +from libtmux.experimental.engines.registry import register_engine + + +def _select_codec(version: int | str) -> ImsgProtocolCodec: + """Return the codec for a tmux imsg protocol version (only v8 is supported).""" + if str(version) == ProtocolV8Codec.version: + return ProtocolV8Codec() + raise UnsupportedProtocolVersion(str(version)) + + +logger = logging.getLogger(__name__) + +_MAX_IMSGSIZE = 16384 +_IMSG_HEADER_SIZE = 16 +_ExitStatus = tuple[int, str | None] +_CLIENT_UTF8 = 0x10000 + + +class ImsgProtocolCodec(t.Protocol): + """Protocol for versioned tmux imsg codecs.""" + + version: str + + def pack_frame(self, frame: ImsgFrame) -> bytes: + """Return wire bytes for a typed imsg frame.""" + + def pack_message(self, msg_type: int, payload: bytes, *, peer_id: int) -> bytes: + """Return a framed tmux imsg message without an attached FD.""" + + def unpack_header(self, data: bytes) -> ImsgHeader: + """Decode a tmux imsg header.""" + + def identify_messages( + self, + *, + cwd: str, + term: str, + tty_name: str, + client_pid: int, + environ: dict[str, str], + flags: int = 0, + features: int = 0, + stdin_fd: int | None = None, + stdout_fd: int | None = None, + ) -> list[ImsgFrame]: + """Build the identify handshake messages for a tmux client.""" + + def command_message(self, argv: tuple[str, ...], *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_COMMAND`` frame.""" + + def parse_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + pid: int, + ) -> object: + """Parse a typed tmux message payload.""" + + def exit_status_from_message( + self, + message: object, + ) -> _ExitStatus | None: + """Return exit metadata if the parsed message encodes it.""" + + def write_open_stream(self, message: object) -> int | None: + """Return the declared stream id from a ``MSG_WRITE_OPEN`` message.""" + + def write_payload(self, message: object) -> tuple[int, bytes] | None: + """Return stream id and bytes from a ``MSG_WRITE`` message.""" + + def write_close_stream(self, message: object) -> int | None: + """Return the closed stream id from a ``MSG_WRITE_CLOSE`` message.""" + + def read_open_stream(self, message: object) -> int | None: + """Return the declared stream id from a ``MSG_READ_OPEN`` message.""" + + def write_ready_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_WRITE_READY`` reply.""" + + def read_done_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_READ_DONE`` reply.""" + + @property + def msg_version(self) -> int: + """Return the numeric ``MSG_VERSION`` message type.""" + + @property + def msg_ready(self) -> int: + """Return the numeric ``MSG_READY`` message type.""" + + @property + def msg_exit(self) -> int: + """Return the numeric ``MSG_EXIT`` message type.""" + + @property + def msg_exited(self) -> int: + """Return the numeric ``MSG_EXITED`` message type.""" + + @property + def msg_shutdown(self) -> int: + """Return the numeric ``MSG_SHUTDOWN`` message type.""" + + @property + def msg_flags(self) -> int: + """Return the numeric ``MSG_FLAGS`` message type.""" + + @property + def msg_write_open(self) -> int: + """Return the numeric ``MSG_WRITE_OPEN`` message type.""" + + @property + def msg_write(self) -> int: + """Return the numeric ``MSG_WRITE`` message type.""" + + @property + def msg_write_close(self) -> int: + """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" + + @property + def msg_read_open(self) -> int: + """Return the numeric ``MSG_READ_OPEN`` message type.""" + + @property + def msg_exiting(self) -> int: + """Return the numeric ``MSG_EXITING`` message type.""" + + def exiting_message(self, *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_EXITING`` notification.""" + + +class _ImsgCommandArgs(t.NamedTuple): + """Parsed tmux CLI arguments needed by the imsg engine.""" + + global_args: tuple[str, ...] + command_argv: tuple[str, ...] + socket_name: str | None + socket_path: str | None + config_file: str | None + command_name: str | None + + +class ImsgEngine: + """Execute tmux commands via the native binary imsg socket protocol.""" + + _startserver_commands = frozenset({"new-session", "start-server"}) + + # Subcommands that ultimately invoke tmux's ``spawn.c`` to start a + # shell. tmux uses the client's environ (built from + # ``MSG_IDENTIFY_ENVIRON`` frames per ``server-client.c:3685``) only + # when actually launching a shell process; for queries / metadata + # commands the environ is allocated, populated, then freed at + # ``MSG_EXIT`` without ever being read. Forwarding the full + # ``os.environ`` (typically ~50-100 vars) per call cost ~one frame + # per env var on the wire — net waste outside the spawn paths. + # See: ``cmd-new-session.c:273``, ``spawn.c:314-324``, + # ``cmd-{new,split,respawn}-{window,pane}.c``, + # ``cmd-display-popup.c``, ``source-file.c``. + _spawn_commands = frozenset( + { + "new-session", + "new-window", + "split-window", + "respawn-pane", + "respawn-window", + "display-popup", + # source-file can run arbitrary commands inside the loaded + # config, including ones that spawn — conservative include + # keeps user expectations stable across engines. + "source-file", + }, + ) + + # Env keys tmux looks up *by name* on the client environ outside + # the shell-spawning paths. Currently a single key: + # * ``TMUX_PANE`` — ``cmd-find.c:93``'s + # ``cmd_find_inside_pane`` fallback uses it to identify which + # pane the calling client is "inside of" when no ``-t`` target + # is given (nested-tmux scenarios — libtmux running from a + # pane inside an existing tmux server). Forwarding it for + # every command keeps the imsg engine's default-target + # resolution semantically identical to subprocess engine. + # ``TMUX`` is also looked up (``server-client.c:240``) but only + # by ``attach-session`` to refuse nested-attach; libtmux hard-routes + # ``attach-session`` through subprocess so the imsg engine never + # exercises that path. + _probe_env_keys = ("TMUX_PANE",) + + def __init__(self, protocol_version: str | int | None = None) -> None: + self.protocol_version = ( + str(protocol_version) if protocol_version is not None else None + ) + self._resolved_tmux_bin: str | None = None + + def run(self, request: CommandRequest) -> CommandResult: + """Execute a tmux command over the server socket.""" + tmux_bin = request.tmux_bin or self._resolve_tmux_bin() + parsed = self._parse_args(request.args) + cmd = [tmux_bin, *parsed.global_args, *parsed.command_argv] + + if parsed.command_name is None or parsed.command_name == "-V": + return self._run_local_command(cmd) + + socket_path = self._resolve_socket_path(parsed) + if parsed.command_name == "start-server": + return self._run_local_command(cmd) + if parsed.command_name in self._startserver_commands and not _server_available( + socket_path + ): + return self._run_local_command(cmd) + + peer_id = int(self.protocol_version or ProtocolV8Codec.version) + retries_remaining = 1 + + while True: + sock: socket.socket | None = None + codec = _select_codec(peer_id) + try: + sock = self._connect(socket_path=socket_path) + return self._run_socket_command( + sock=sock, + codec=codec, + peer_id=peer_id, + command_name=parsed.command_name, + command_argv=parsed.command_argv, + cmd=cmd, + ) + except _NoServerError as error: + if parsed.command_name in self._startserver_commands: + return self._run_local_command(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=(), + stderr=(error.message,), + returncode=1, + ) + except (BrokenPipeError, ConnectionResetError) as error: + # Server began shutdown between connect() and the first + # send/recv: the socket file still exists so connect succeeded, + # but the kernel returns EPIPE/ECONNRESET on the first I/O. + # Mirror tmux's CLIENT_EXIT_LOST_SERVER behavior — present a + # clean "no server running" CommandResult instead of leaking + # the transport exception. + if parsed.command_name in self._startserver_commands: + return self._run_local_command(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=(), + stderr=(self._no_server_message(socket_path, error),), + returncode=1, + ) + except _ProtocolVersionMismatch as mismatch: + if retries_remaining == 0: + raise UnsupportedProtocolVersion( + mismatch.server_version, + ) from None + retries_remaining -= 1 + peer_id = int(mismatch.server_version) + self.protocol_version = mismatch.server_version + finally: + if sock is not None: + sock.close() + + def _resolve_tmux_bin(self) -> str: + """Return the tmux binary path, memoized per engine instance. + + ``shutil.which`` walks ``$PATH`` on every call (~50µs); the engine + invokes it on the hot path of every command, so caching the + result for the lifetime of the engine instance is a free win. + ``TmuxCommandNotFound`` is intentionally not memoized. + """ + if self._resolved_tmux_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_tmux_bin = resolved + return self._resolved_tmux_bin + + def run_batch( + self, + requests: t.Sequence[CommandRequest], + ) -> list[CommandResult]: + """Loop over ``run`` — imsg opens a fresh socket per call. + + No batching benefit but provided for uniform API: callers can + use ``run_batch`` regardless of engine and get the right + ordered list of results. + """ + return [self.run(req) for req in requests] + + def _parse_args(self, args: tuple[str, ...]) -> _ImsgCommandArgs: + global_args: list[str] = [] + command_argv: list[str] = [] + socket_name: str | None = None + socket_path: str | None = None + config_file: str | None = None + + index = 0 + while index < len(args): + arg = args[index] + if arg == "-V": + command_argv.append(arg) + break + if arg in {"-L", "-S", "-f"}: + if index + 1 >= len(args): + command_argv.append(arg) + break + value = args[index + 1] + global_args.extend((arg, value)) + if arg == "-L": + socket_name = value + elif arg == "-S": + socket_path = value + else: + config_file = value + index += 2 + continue + if arg.startswith("-L") and len(arg) > 2: + socket_name = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg.startswith("-S") and len(arg) > 2: + socket_path = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg.startswith("-f") and len(arg) > 2: + config_file = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg in {"-2", "-8"}: + global_args.append(arg) + index += 1 + continue + + command_argv.extend(args[index:]) + break + + command_name = command_argv[0] if command_argv else None + return _ImsgCommandArgs( + global_args=tuple(global_args), + command_argv=tuple(command_argv), + socket_name=socket_name, + socket_path=socket_path, + config_file=config_file, + command_name=command_name, + ) + + def _resolve_socket_path(self, parsed: _ImsgCommandArgs) -> str: + if parsed.socket_path is not None: + return parsed.socket_path + + socket_name = parsed.socket_name or "default" + tmux_tmpdir = pathlib.Path(os.getenv("TMUX_TMPDIR", "/tmp")) + return str(tmux_tmpdir / f"tmux-{os.geteuid()}" / socket_name) + + def _connect( + self, + *, + socket_path: str, + ) -> socket.socket: + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(socket_path) + except OSError as error: + sock.close() + if error.errno not in {errno.ENOENT, errno.ECONNREFUSED}: + raise + raise _NoServerError( + self._no_server_message(socket_path, error), + ) from error + return sock + + def _no_server_message(self, socket_path: str, error: OSError) -> str: + if error.errno == errno.ECONNREFUSED: + return f"error connecting to {socket_path}" + return f"no server running on {socket_path}" + + def _client_flags(self) -> int: + if os.environ.get("TMUX"): + return _CLIENT_UTF8 + + locale = ( + os.environ.get("LC_ALL") + or os.environ.get("LC_CTYPE") + or os.environ.get("LANG") + or "" + ) + locale = locale.upper() + if "UTF-8" in locale or "UTF8" in locale: + return _CLIENT_UTF8 + return 0 + + def _run_local_command(self, cmd: list[str]) -> CommandResult: + exit_code, stdout, stderr = _spawn_and_capture(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout), + stderr=tuple(stderr), + returncode=exit_code, + ) + + def _run_socket_command( + self, + *, + sock: socket.socket, + codec: ImsgProtocolCodec, + peer_id: int, + command_name: str | None, + command_argv: tuple[str, ...], + cmd: list[str], + ) -> CommandResult: + stdin_fd = _duplicate_fd(0) + stdout_fd = _duplicate_fd(1) + # Gated env forwarding: tmux only reads ``c->environ`` from + # ``spawn.c`` (and its callers) when actually launching a + # shell — every other command path frees ``c->environ`` + # without reading it. Sending the full ``os.environ`` would + # cost one ``MSG_IDENTIFY_ENVIRON`` frame per env var (~89 on + # this host) per command; for non-spawning commands those + # frames are net waste. Forward the full env only when + # ``command_name`` is in :attr:`_spawn_commands`; for everything + # else, forward only :attr:`_probe_env_keys` so tmux's + # named-lookup paths (``cmd-find.c``'s ``TMUX_PANE`` fallback) + # keep matching subprocess-engine semantics. + if command_name in self._spawn_commands: + environ_to_send: dict[str, str] = dict(os.environ) + else: + environ_to_send = { + key: os.environ[key] + for key in self._probe_env_keys + if key in os.environ + } + identify_frames = codec.identify_messages( + cwd=str(pathlib.Path.cwd()), + term=os.environ.get("TERM", "unknown") or "unknown", + tty_name="", + client_pid=os.getpid(), + environ=environ_to_send, + flags=self._client_flags(), + features=0, + stdin_fd=stdin_fd, + stdout_fd=stdout_fd, + ) + logger.debug( + "sending imsg identify burst", + extra={ + "tmux_protocol_version": codec.version, + "tmux_identify_frames": len(identify_frames), + "tmux_command_argv": list(command_argv), + }, + ) + + stdout_streams: set[int] = set() + stderr_streams: set[int] = set() + stdout_buffer = bytearray() + stderr_buffer = bytearray() + exit_code = 0 + exit_message: str | None = None + seen_exit = False + + transport = _SelectorSocketTransport(sock) + try: + transport.send_frames(codec, identify_frames) + command_frame = codec.command_message(command_argv, peer_id=peer_id) + transport.send_frame(codec, command_frame) + + while True: + frame = transport.recv_frame(codec) + msg_type = frame.header.msg_type + peer = frame.header.peer_id + pid = frame.header.pid + payload = frame.payload + logger.debug( + "received imsg message", + extra={ + "tmux_protocol_version": codec.version, + "tmux_message_type": msg_type, + "tmux_message_peer": peer, + "tmux_message_pid": pid, + "tmux_message_len": len(payload), + "tmux_message_has_fd": frame.header.has_fd, + "tmux_command_argv": list(command_argv), + }, + ) + if msg_type == codec.msg_version: + _close_fd(frame.fd) + raise _ProtocolVersionMismatch(str(peer & 0xFF)) + + try: + message: object = codec.parse_message( + msg_type, + payload, + peer_id=peer, + pid=pid, + ) + finally: + _close_fd(frame.fd) + + if msg_type == codec.msg_ready: + continue + if msg_type == codec.msg_flags: + continue + + stream = codec.write_open_stream(message) + if stream is not None: + if stream == 2: + stderr_streams.add(stream) + else: + stdout_streams.add(stream) + transport.send_frame( + codec, + codec.write_ready_message(stream, 0, peer_id=peer_id), + ) + continue + + payload_data = codec.write_payload(message) + if payload_data is not None: + stream_id, data = payload_data + if stream_id in stderr_streams: + stderr_buffer.extend(data) + else: + stdout_buffer.extend(data) + continue + + close_stream = codec.write_close_stream(message) + if close_stream is not None: + continue + + read_stream = codec.read_open_stream(message) + if read_stream is not None: + transport.send_frame( + codec, + codec.read_done_message( + read_stream, + errno.EBADF, + peer_id=peer_id, + ), + ) + continue + + exit_status = codec.exit_status_from_message(message) + if exit_status is not None: + exit_code, exit_message = exit_status + seen_exit = True + transport.send_frame( + codec, + codec.exiting_message(peer_id=peer_id), + ) + continue + + if msg_type == codec.msg_shutdown: + exit_code = 1 + seen_exit = True + transport.send_frame( + codec, + codec.exiting_message(peer_id=peer_id), + ) + continue + + if msg_type == codec.msg_exited: + break + + if seen_exit: + break + finally: + transport.close() + + stdout_lines = _split_output(bytes(stdout_buffer)) + stderr_lines = _split_output(bytes(stderr_buffer)) + if exit_message: + stderr_lines.append(exit_message) + if "has-session" in cmd and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=exit_code, + ) + + +class _ProtocolVersionMismatch(RuntimeError): + """Internal signal for retrying with a negotiated protocol version.""" + + def __init__(self, server_version: str) -> None: + super().__init__(server_version) + self.server_version = server_version + + +class _NoServerError(RuntimeError): + """Internal signal for commands against a missing tmux socket.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class _SelectorSocketTransport: + """Selector-backed imsg transport for Unix domain sockets.""" + + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.sock.setblocking(False) + self._selector = selectors.DefaultSelector() + self._selector.register(sock, selectors.EVENT_READ) + self._buffer = bytearray() + self._pending_fds: list[int] = [] + + def close(self) -> None: + """Close selector state and any unclaimed descriptors.""" + with contextlib.suppress(KeyError, ValueError): + self._selector.unregister(self.sock) + self._selector.close() + for fd in self._pending_fds: + _close_fd(fd) + self._pending_fds.clear() + + def send_frames( + self, + codec: ImsgProtocolCodec, + frames: list[ImsgFrame], + ) -> None: + """Send a sequence of frames and close unsent descriptors on failure.""" + sent = 0 + try: + for frame in frames: + self.send_frame(codec, frame) + sent += 1 + finally: + for frame in frames[sent:]: + _close_fd(frame.fd) + + def send_frame(self, codec: ImsgProtocolCodec, frame: ImsgFrame) -> None: + """Send one imsg frame, including an optional SCM_RIGHTS descriptor.""" + data = codec.pack_frame(frame) + if frame.fd is not None: + self._send_frame_with_fd(data, frame.fd) + return + self._send_all(data) + + def recv_frame(self, codec: ImsgProtocolCodec) -> ImsgFrame: + """Receive one complete imsg frame.""" + while len(self._buffer) < _IMSG_HEADER_SIZE: + self._recv_more() + + header = codec.unpack_header(bytes(self._buffer[:_IMSG_HEADER_SIZE])) + while len(self._buffer) < header.length: + self._recv_more() + + payload = bytes(self._buffer[_IMSG_HEADER_SIZE : header.length]) + del self._buffer[: header.length] + + fd: int | None = None + if header.has_fd and self._pending_fds: + fd = self._pending_fds.pop(0) + + return ImsgFrame(header=header, payload=payload, fd=fd) + + def _wait_for(self, event: int) -> None: + self._selector.modify(self.sock, event) + self._selector.select() + + def _send_all(self, data: bytes) -> None: + # Optimistic send: tmux's imsg frames are tiny (≤16 KiB) and a + # fresh AF_UNIX SOCK_STREAM socket has plenty of buffer + # capacity, so the first send almost always succeeds. Hitting + # the selector only on real BlockingIOError replaces two + # syscalls per send (selector.modify + epoll_wait) with zero + # in the common case — a measurable win on the imsg engine + # since it issues ~100 sends per command. + offset = 0 + sock = self.sock + while offset < len(data): + try: + sent = sock.send(data[offset:]) + except BlockingIOError: + self._wait_for(selectors.EVENT_WRITE) + continue + if sent == 0: + msg = "tmux socket closed during protocol write" + raise ImsgProtocolError(msg) + offset += sent + + def _send_frame_with_fd(self, data: bytes, fd: int) -> None: + fds = array.array("i", [fd]) + ancillary = [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds.tobytes())] + try: + sock = self.sock + while True: + try: + sent = sock.sendmsg([data], ancillary) + except BlockingIOError: + self._wait_for(selectors.EVENT_WRITE) + continue + break + if sent != len(data): + msg = "tmux imsg frame with FD was partially written" + raise ImsgProtocolError(msg) + finally: + _close_fd(fd) + + def _recv_more(self) -> None: + # Symmetric optimistic recv: the wire half of every imsg + # exchange is paced by tmux's reply rate, so by the time + # Python re-enters the read loop the kernel buffer typically + # already has bytes. Skip the upfront selector wait and only + # block on real BlockingIOError. + fd_size = array.array("i").itemsize + sock = self.sock + while True: + try: + data, ancillary, _flags, _addr = sock.recvmsg( + 65535, + socket.CMSG_SPACE(fd_size), + ) + except BlockingIOError: + self._wait_for(selectors.EVENT_READ) + continue + break + if not data: + msg = "tmux socket closed during protocol exchange" + raise ImsgProtocolError(msg) + + self._buffer.extend(data) + for level, msg_type, cmsg_data in ancillary: + if level != socket.SOL_SOCKET or msg_type != socket.SCM_RIGHTS: + continue + fds = array.array("i") + fds.frombytes(cmsg_data[: len(cmsg_data) - (len(cmsg_data) % fd_size)]) + for index, fd in enumerate(fds): + if index == 0: + self._pending_fds.append(fd) + else: + _close_fd(fd) + + +def _spawn_and_capture(command: list[str]) -> tuple[int, list[str], list[str]]: + """Run a command without subprocess and capture its output.""" + stdout_read, stdout_write = os.pipe() + stderr_read, stderr_write = os.pipe() + file_actions = [ + (os.POSIX_SPAWN_DUP2, stdout_write, 1), + (os.POSIX_SPAWN_DUP2, stderr_write, 2), + (os.POSIX_SPAWN_CLOSE, stdout_read), + (os.POSIX_SPAWN_CLOSE, stderr_read), + ] + + try: + if "/" in command[0]: + pid = os.posix_spawn( + command[0], + command, + os.environ, + file_actions=file_actions, + ) + else: + pid = os.posix_spawnp( + command[0], + command, + os.environ, + file_actions=file_actions, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + finally: + os.close(stdout_write) + os.close(stderr_write) + + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + + os.set_blocking(stdout_read, False) + os.set_blocking(stderr_read, False) + + selector = selectors.DefaultSelector() + streams = { + stdout_read: stdout_chunks, + stderr_read: stderr_chunks, + } + selector.register(stdout_read, selectors.EVENT_READ) + selector.register(stderr_read, selectors.EVENT_READ) + + try: + while streams: + for key, _mask in selector.select(): + fd = key.fd + try: + chunk = os.read(fd, 65535) + except BlockingIOError: + continue + if chunk: + streams[fd].append(chunk) + continue + selector.unregister(fd) + del streams[fd] + finally: + selector.close() + + os.close(stdout_read) + os.close(stderr_read) + _pid, status = os.waitpid(pid, 0) + exit_code = os.waitstatus_to_exitcode(status) + + stdout_lines = _split_output(b"".join(stdout_chunks)) + stderr_lines = _split_output(b"".join(stderr_chunks)) + return exit_code, stdout_lines, stderr_lines + + +def _duplicate_fd(fd: int) -> int | None: + """Duplicate a descriptor for SCM_RIGHTS ownership transfer.""" + with contextlib.suppress(OSError): + return os.dup(fd) + return None + + +def _close_fd(fd: int | None) -> None: + """Close a descriptor if one is present.""" + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + + +def _split_output(data: bytes) -> list[str]: + """Split tmux output into newline-delimited text lines.""" + text = data.decode("utf-8", errors="backslashreplace") + lines = text.split("\n") + while lines and lines[-1] == "": + lines.pop() + return lines + + +def _server_available(socket_path: str) -> bool: + """Return whether a tmux server is currently listening on the socket path.""" + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.connect(socket_path) + except OSError: + return False + finally: + probe.close() + return True + + +register_engine("imsg", ImsgEngine) diff --git a/src/libtmux/experimental/engines/imsg/exc.py b/src/libtmux/experimental/engines/imsg/exc.py new file mode 100644 index 000000000..99d142e37 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/exc.py @@ -0,0 +1,22 @@ +"""Exceptions for the experimental imsg engine.""" + +from __future__ import annotations + +from libtmux.exc import LibTmuxException + + +class ImsgError(LibTmuxException): + """Base error for the native imsg engine.""" + + +class ImsgProtocolError(ImsgError): + """The imsg wire protocol was violated (bad frame, size, or framing).""" + + +class UnsupportedProtocolVersion(ImsgError): + """The tmux server speaks an imsg protocol version this engine lacks.""" + + def __init__(self, version: str) -> None: + self.version = version + msg = f"unsupported tmux imsg protocol version: {version}" + super().__init__(msg) diff --git a/src/libtmux/experimental/engines/imsg/types.py b/src/libtmux/experimental/engines/imsg/types.py new file mode 100644 index 000000000..0812cf26a --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/types.py @@ -0,0 +1,28 @@ +"""Typed imsg frame primitives shared by protocol versions.""" + +from __future__ import annotations + +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class ImsgHeader: + """Decoded imsg header. + + ``length`` is the full frame length without the imsg FD marker bit. + """ + + msg_type: int + length: int + peer_id: int + pid: int + has_fd: bool = False + + +@dataclasses.dataclass(frozen=True) +class ImsgFrame: + """A framed tmux imsg message plus an optional SCM_RIGHTS descriptor.""" + + header: ImsgHeader + payload: bytes = b"" + fd: int | None = None diff --git a/src/libtmux/experimental/engines/imsg/v8.py b/src/libtmux/experimental/engines/imsg/v8.py new file mode 100644 index 000000000..3221da5f5 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/v8.py @@ -0,0 +1,543 @@ +"""tmux imsg protocol version 8.""" + +from __future__ import annotations + +import dataclasses +import enum +import struct +import typing as t + +from libtmux.experimental.engines.imsg.exc import ImsgProtocolError +from libtmux.experimental.engines.imsg.types import ImsgFrame, ImsgHeader + +IMSG_HEADER_SIZE = 16 +MAX_IMSGSIZE = 16384 +IMSG_FD_MARK = 0x80000000 + +_HEADER = struct.Struct("=IIII") +_INT32 = struct.Struct("=i") +_UINT64 = struct.Struct("=Q") +_WRITE_OPEN = struct.Struct("=iii") +_WRITE_DATA = struct.Struct("=i") +_WRITE_READY = struct.Struct("=ii") +_WRITE_CLOSE = struct.Struct("=i") +_READ_OPEN = struct.Struct("=ii") +_READ_DONE = struct.Struct("=ii") +_ExitStatus = tuple[int, str | None] + + +class MessageType(enum.IntEnum): + """Known tmux protocol v8 message types from ``tmux-protocol.h``.""" + + MSG_VERSION = 12 + MSG_IDENTIFY_FLAGS = 100 + MSG_IDENTIFY_TERM = 101 + MSG_IDENTIFY_TTYNAME = 102 + MSG_IDENTIFY_OLDCWD = 103 + MSG_IDENTIFY_STDIN = 104 + MSG_IDENTIFY_ENVIRON = 105 + MSG_IDENTIFY_DONE = 106 + MSG_IDENTIFY_CLIENTPID = 107 + MSG_IDENTIFY_CWD = 108 + MSG_IDENTIFY_FEATURES = 109 + MSG_IDENTIFY_STDOUT = 110 + MSG_IDENTIFY_LONGFLAGS = 111 + MSG_IDENTIFY_TERMINFO = 112 + MSG_COMMAND = 200 + MSG_DETACH = 201 + MSG_DETACHKILL = 202 + MSG_EXIT = 203 + MSG_EXITED = 204 + MSG_EXITING = 205 + MSG_LOCK = 206 + MSG_READY = 207 + MSG_RESIZE = 208 + MSG_SHELL = 209 + MSG_SHUTDOWN = 210 + MSG_OLDSTDERR = 211 + MSG_OLDSTDIN = 212 + MSG_OLDSTDOUT = 213 + MSG_SUSPEND = 214 + MSG_UNLOCK = 215 + MSG_WAKEUP = 216 + MSG_EXEC = 217 + MSG_FLAGS = 218 + MSG_READ_OPEN = 300 + MSG_READ = 301 + MSG_READ_DONE = 302 + MSG_WRITE_OPEN = 303 + MSG_WRITE = 304 + MSG_WRITE_READY = 305 + MSG_WRITE_CLOSE = 306 + MSG_READ_CANCEL = 307 + + +@dataclasses.dataclass(frozen=True) +class WriteOpenMessage: + """Parsed ``MSG_WRITE_OPEN`` payload.""" + + stream: int + fd: int + flags: int + path: str + + +@dataclasses.dataclass(frozen=True) +class WriteDataMessage: + """Parsed ``MSG_WRITE`` payload.""" + + stream: int + data: bytes + + +@dataclasses.dataclass(frozen=True) +class WriteReadyMessage: + """Parsed ``MSG_WRITE_READY`` payload.""" + + stream: int + error: int + + +@dataclasses.dataclass(frozen=True) +class WriteCloseMessage: + """Parsed ``MSG_WRITE_CLOSE`` payload.""" + + stream: int + + +@dataclasses.dataclass(frozen=True) +class ReadOpenMessage: + """Parsed ``MSG_READ_OPEN`` payload.""" + + stream: int + fd: int + path: str + + +@dataclasses.dataclass(frozen=True) +class ReadDoneMessage: + """Parsed ``MSG_READ_DONE`` payload.""" + + stream: int + error: int + + +@dataclasses.dataclass(frozen=True) +class ExitMessage: + """Parsed ``MSG_EXIT`` payload.""" + + returncode: int + message: str | None + + +@dataclasses.dataclass(frozen=True) +class RawMessage: + """Payload for message types without a dedicated parser.""" + + payload: bytes + + +ParsedMessage: t.TypeAlias = ( + WriteOpenMessage + | WriteDataMessage + | WriteReadyMessage + | WriteCloseMessage + | ReadOpenMessage + | ReadDoneMessage + | ExitMessage + | RawMessage +) + + +class ProtocolV8Codec: + """Typed codec for tmux binary protocol version 8.""" + + version = "8" + + @property + def msg_version(self) -> int: + """Return the numeric ``MSG_VERSION`` message type.""" + return int(MessageType.MSG_VERSION) + + @property + def msg_ready(self) -> int: + """Return the numeric ``MSG_READY`` message type.""" + return int(MessageType.MSG_READY) + + @property + def msg_exit(self) -> int: + """Return the numeric ``MSG_EXIT`` message type.""" + return int(MessageType.MSG_EXIT) + + @property + def msg_exited(self) -> int: + """Return the numeric ``MSG_EXITED`` message type.""" + return int(MessageType.MSG_EXITED) + + @property + def msg_shutdown(self) -> int: + """Return the numeric ``MSG_SHUTDOWN`` message type.""" + return int(MessageType.MSG_SHUTDOWN) + + @property + def msg_flags(self) -> int: + """Return the numeric ``MSG_FLAGS`` message type.""" + return int(MessageType.MSG_FLAGS) + + @property + def msg_write_open(self) -> int: + """Return the numeric ``MSG_WRITE_OPEN`` message type.""" + return int(MessageType.MSG_WRITE_OPEN) + + @property + def msg_write(self) -> int: + """Return the numeric ``MSG_WRITE`` message type.""" + return int(MessageType.MSG_WRITE) + + @property + def msg_write_close(self) -> int: + """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" + return int(MessageType.MSG_WRITE_CLOSE) + + @property + def msg_read_open(self) -> int: + """Return the numeric ``MSG_READ_OPEN`` message type.""" + return int(MessageType.MSG_READ_OPEN) + + @property + def msg_exiting(self) -> int: + """Return the numeric ``MSG_EXITING`` message type.""" + return int(MessageType.MSG_EXITING) + + def frame_message( + self, + msg_type: int | MessageType, + payload: bytes, + *, + peer_id: int, + fd: int | None = None, + ) -> ImsgFrame: + """Return a typed imsg frame.""" + length = IMSG_HEADER_SIZE + len(payload) + if length > MAX_IMSGSIZE: + msg = f"tmux imsg payload too large: {len(payload)} bytes" + raise ImsgProtocolError(msg) + return ImsgFrame( + header=ImsgHeader( + msg_type=int(msg_type), + length=length, + peer_id=peer_id, + pid=0, + has_fd=fd is not None, + ), + payload=payload, + fd=fd, + ) + + def pack_frame(self, frame: ImsgFrame) -> bytes: + """Return wire bytes for a typed imsg frame.""" + expected_payload_len = frame.header.length - IMSG_HEADER_SIZE + if expected_payload_len != len(frame.payload): + msg = ( + "tmux imsg frame length does not match payload size: " + f"{frame.header.length} != {IMSG_HEADER_SIZE + len(frame.payload)}" + ) + raise ImsgProtocolError(msg) + if frame.header.has_fd != (frame.fd is not None): + msg = "tmux imsg frame FD marker does not match descriptor" + raise ImsgProtocolError(msg) + + encoded_length = frame.header.length + if frame.header.has_fd: + encoded_length |= IMSG_FD_MARK + header = _HEADER.pack( + frame.header.msg_type, + encoded_length, + frame.header.peer_id, + frame.header.pid, + ) + return header + frame.payload + + def pack_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + ) -> bytes: + """Return a framed tmux imsg message without an attached FD.""" + return self.pack_frame( + self.frame_message(msg_type, payload, peer_id=peer_id), + ) + + def unpack_header(self, data: bytes) -> ImsgHeader: + """Decode and validate a tmux imsg header.""" + if len(data) != IMSG_HEADER_SIZE: + msg = f"tmux imsg header must be {IMSG_HEADER_SIZE} bytes" + raise ImsgProtocolError(msg) + + msg_type, encoded_length, peer_id, pid = _HEADER.unpack(data) + has_fd = bool(encoded_length & IMSG_FD_MARK) + length = encoded_length & ~IMSG_FD_MARK + if length < IMSG_HEADER_SIZE or length > MAX_IMSGSIZE: + msg = f"Invalid tmux imsg length: {length}" + raise ImsgProtocolError(msg) + return ImsgHeader( + msg_type=msg_type, + length=length, + peer_id=peer_id, + pid=pid, + has_fd=has_fd, + ) + + def identify_messages( + self, + *, + cwd: str, + term: str, + tty_name: str, + client_pid: int, + environ: dict[str, str], + flags: int = 0, + features: int = 0, + stdin_fd: int | None = None, + stdout_fd: int | None = None, + ) -> list[ImsgFrame]: + """Build the identify handshake messages for a tmux client.""" + peer_id = int(self.version) + messages = [ + self.frame_message( + MessageType.MSG_IDENTIFY_LONGFLAGS, + _UINT64.pack(flags), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_LONGFLAGS, + _UINT64.pack(flags), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_TERM, + _c_string(term), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_FEATURES, + _INT32.pack(features), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_TTYNAME, + _c_string(tty_name), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_CWD, + _c_string(cwd), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_STDIN, + b"", + peer_id=peer_id, + fd=stdin_fd, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_STDOUT, + b"", + peer_id=peer_id, + fd=stdout_fd, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_CLIENTPID, + _INT32.pack(client_pid), + peer_id=peer_id, + ), + ] + for key, value in environ.items(): + encoded = _c_string(f"{key}={value}") + if len(encoded) > MAX_IMSGSIZE - IMSG_HEADER_SIZE: + continue + messages.append( + self.frame_message( + MessageType.MSG_IDENTIFY_ENVIRON, + encoded, + peer_id=peer_id, + ), + ) + messages.append( + self.frame_message( + MessageType.MSG_IDENTIFY_DONE, + b"", + peer_id=peer_id, + ), + ) + return messages + + def command_message(self, argv: tuple[str, ...], *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_COMMAND`` frame.""" + payload = _INT32.pack(len(argv)) + b"".join(_c_string(arg) for arg in argv) + return self.frame_message( + MessageType.MSG_COMMAND, + payload, + peer_id=peer_id, + ) + + def parse_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + pid: int, + ) -> ParsedMessage: + """Parse a typed tmux message payload.""" + del peer_id, pid + if msg_type == int(MessageType.MSG_WRITE_OPEN): + _require_min_size(payload, _WRITE_OPEN.size, "MSG_WRITE_OPEN") + stream, fd, flags = _WRITE_OPEN.unpack_from(payload) + path = _decode_c_string(payload[_WRITE_OPEN.size :]) + return WriteOpenMessage(stream=stream, fd=fd, flags=flags, path=path) + if msg_type == int(MessageType.MSG_WRITE): + _require_min_size(payload, _WRITE_DATA.size, "MSG_WRITE") + (stream,) = _WRITE_DATA.unpack_from(payload) + return WriteDataMessage(stream=stream, data=payload[_WRITE_DATA.size :]) + if msg_type == int(MessageType.MSG_WRITE_READY): + _require_exact_size(payload, _WRITE_READY.size, "MSG_WRITE_READY") + stream, error = _WRITE_READY.unpack(payload) + return WriteReadyMessage(stream=stream, error=error) + if msg_type == int(MessageType.MSG_WRITE_CLOSE): + _require_exact_size(payload, _WRITE_CLOSE.size, "MSG_WRITE_CLOSE") + (stream,) = _WRITE_CLOSE.unpack(payload) + return WriteCloseMessage(stream=stream) + if msg_type == int(MessageType.MSG_READ_OPEN): + _require_min_size(payload, _READ_OPEN.size, "MSG_READ_OPEN") + stream, fd = _READ_OPEN.unpack_from(payload) + path = _decode_c_string(payload[_READ_OPEN.size :]) + return ReadOpenMessage(stream=stream, fd=fd, path=path) + if msg_type == int(MessageType.MSG_READ_DONE): + _require_exact_size(payload, _READ_DONE.size, "MSG_READ_DONE") + stream, error = _READ_DONE.unpack(payload) + return ReadDoneMessage(stream=stream, error=error) + if msg_type == int(MessageType.MSG_EXIT): + return _parse_exit_message(payload) + return RawMessage(payload=payload) + + def exit_status_from_message(self, message: object) -> _ExitStatus | None: + """Return exit metadata if the parsed message encodes it.""" + if isinstance(message, ExitMessage): + return message.returncode, message.message + return None + + def write_open_stream(self, message: object) -> int | None: + """Return the stream id from a ``MSG_WRITE_OPEN`` message.""" + if isinstance(message, WriteOpenMessage): + return message.stream + return None + + def write_payload(self, message: object) -> tuple[int, bytes] | None: + """Return stream id and bytes from a ``MSG_WRITE`` message.""" + if isinstance(message, WriteDataMessage): + return message.stream, message.data + return None + + def write_close_stream(self, message: object) -> int | None: + """Return the closed stream id from a ``MSG_WRITE_CLOSE`` message.""" + if isinstance(message, WriteCloseMessage): + return message.stream + return None + + def read_open_stream(self, message: object) -> int | None: + """Return the stream id from a ``MSG_READ_OPEN`` message.""" + if isinstance(message, ReadOpenMessage): + return message.stream + return None + + def write_ready_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_WRITE_READY`` reply.""" + return self.frame_message( + MessageType.MSG_WRITE_READY, + _WRITE_READY.pack(stream, error_code), + peer_id=peer_id, + ) + + def read_done_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_READ_DONE`` reply.""" + return self.frame_message( + MessageType.MSG_READ_DONE, + _READ_DONE.pack(stream, error_code), + peer_id=peer_id, + ) + + def exiting_message(self, *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_EXITING`` notification.""" + return self.frame_message(MessageType.MSG_EXITING, b"", peer_id=peer_id) + + +def _c_string(value: str) -> bytes: + return value.encode("utf-8") + b"\0" + + +def _decode_c_string(data: bytes) -> str: + if not data: + return "" + if data[-1] != 0: + msg = "tmux imsg string payload is not NUL terminated" + raise ImsgProtocolError(msg) + return data[:-1].decode("utf-8", errors="backslashreplace") + + +def _require_min_size(payload: bytes, min_size: int, name: str) -> None: + if len(payload) < min_size: + msg = f"bad {name} payload size: {len(payload)}" + raise ImsgProtocolError(msg) + + +def _require_exact_size(payload: bytes, expected_size: int, name: str) -> None: + if len(payload) != expected_size: + msg = f"bad {name} payload size: {len(payload)}" + raise ImsgProtocolError(msg) + + +def _parse_exit_message(payload: bytes) -> ExitMessage: + if len(payload) < _INT32.size and payload: + msg = "bad MSG_EXIT payload size" + raise ImsgProtocolError(msg) + + returncode = 0 + message: str | None = None + if len(payload) >= _INT32.size: + (returncode,) = _INT32.unpack_from(payload) + if len(payload) > _INT32.size: + message = _decode_c_string(payload[_INT32.size :]) or None + return ExitMessage(returncode=returncode, message=message) + + +__all__ = ( + "IMSG_FD_MARK", + "IMSG_HEADER_SIZE", + "MAX_IMSGSIZE", + "ExitMessage", + "MessageType", + "ParsedMessage", + "ProtocolV8Codec", + "RawMessage", + "ReadDoneMessage", + "ReadOpenMessage", + "WriteCloseMessage", + "WriteDataMessage", + "WriteOpenMessage", + "WriteReadyMessage", +) diff --git a/tests/experimental/engines/__init__.py b/tests/experimental/engines/__init__.py new file mode 100644 index 000000000..bdf24ec3b --- /dev/null +++ b/tests/experimental/engines/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.engines.""" + +from __future__ import annotations diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py new file mode 100644 index 000000000..0715c2025 --- /dev/null +++ b/tests/experimental/engines/test_imsg.py @@ -0,0 +1,93 @@ +"""Tests for the native imsg engine (codec unit tests + live tmux parity). + +The prototype this is ported from only ever tested against a fake socketpair +server; the live parity test here is the real wire-compatibility proof against a +tmux built from source, and it runs across the CI tmux matrix. +""" + +from __future__ import annotations + +import socket +import typing as t + +import pytest + +from libtmux.experimental.engines import ( + CommandRequest, + ImsgEngine, + SubprocessEngine, + available_engines, + create_engine, +) +from libtmux.experimental.engines.imsg.v8 import IMSG_HEADER_SIZE, ProtocolV8Codec + +if t.TYPE_CHECKING: + from libtmux.session import Session + +needs_af_unix = pytest.mark.skipif( + not hasattr(socket, "AF_UNIX"), + reason="imsg engine needs AF_UNIX sockets (POSIX only)", +) + + +def test_imsg_registered() -> None: + """The imsg engine is registered and constructible by name.""" + assert "imsg" in available_engines() + assert type(create_engine("imsg")).__name__ == "ImsgEngine" + + +def test_v8_codec_header_round_trip() -> None: + """A v8 frame packs to wire bytes and its header unpacks back (no tmux).""" + codec = ProtocolV8Codec() + payload = b"hello\x00" + frame = codec.frame_message(200, payload, peer_id=8) + wire = codec.pack_frame(frame) + + assert len(wire) == IMSG_HEADER_SIZE + len(payload) + header = codec.unpack_header(wire[:IMSG_HEADER_SIZE]) + assert header.msg_type == 200 + assert header.peer_id == 8 # peer_id carries PROTOCOL_VERSION + assert header.length == IMSG_HEADER_SIZE + len(payload) + assert header.has_fd is False + + +def test_v8_command_message_packs_argc_and_argv() -> None: + """A MSG_COMMAND frame encodes argc + NUL-joined argv (no tmux).""" + codec = ProtocolV8Codec() + frame = codec.command_message(("list-sessions", "-F", "#{session_id}"), peer_id=8) + # int32 argc=3 then three NUL-terminated args + assert frame.payload.startswith(b"\x03\x00\x00\x00") + assert frame.payload.endswith(b"#{session_id}\x00") + + +def _socket_prefix(server: t.Any) -> tuple[str, ...]: + """Build the -L/-S flag that targets the test server's socket.""" + if server.socket_name: + return (f"-L{server.socket_name}",) + return (f"-S{server.socket_path}",) + + +@needs_af_unix +def test_imsg_subprocess_parity(session: Session) -> None: + """Imsg and subprocess engines return identical output for read commands. + + This is the wire-compatibility proof: the same typed CommandResult from + speaking tmux's binary protocol directly and from forking the tmux CLI. + """ + server = session.server + prefix = _socket_prefix(server) + session_id = session.session_id + assert session_id is not None + imsg = ImsgEngine() + classic = SubprocessEngine() + + def parity(*cmd: str) -> None: + request = CommandRequest.from_args(*prefix, *cmd) + via_imsg = imsg.run(request) + via_subprocess = classic.run(request) + assert via_imsg.returncode == via_subprocess.returncode, cmd + assert via_imsg.stdout == via_subprocess.stdout, cmd + + parity("display-message", "-p", "-t", session_id, "#{session_id}") + parity("list-sessions", "-F", "#{session_id}") + parity("has-session", "-t", session_id) From 2c1af35859ffb0ee737da3635cd2a873471a44fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:01:27 -0500 Subject: [PATCH 015/223] Facade(feat): Complete the facade matrix (Server/Session/Client) why: Finish the mode-in-the-type matrix so every tmux scope has eager/lazy/async facades, and add the client-scoped ops a Client facade needs. The matrix is now 5 scopes x 3 modes, all over the shared spine. what: - ops: detach-client, refresh-client, switch-client (AckResult, client scope; switch-client renders -c/-t rather than the generic target) - facade: LazyServer/AsyncServer, LazySession/AsyncSession, and the new client scope (EagerClient/LazyClient/AsyncClient); AsyncServer.for_server binds the async engine to a live Server - tests: a lazy full Server->Session->Window->pane plan, async navigation, and eager/lazy/async client methods --- src/libtmux/experimental/facade/__init__.py | 38 ++++-- src/libtmux/experimental/facade/client.py | 124 ++++++++++++++++++ src/libtmux/experimental/facade/server.py | 78 ++++++++++- src/libtmux/experimental/facade/session.py | 97 +++++++++++++- src/libtmux/experimental/ops/__init__.py | 6 + src/libtmux/experimental/ops/_ops/__init__.py | 6 + .../experimental/ops/_ops/detach_client.py | 36 +++++ .../experimental/ops/_ops/refresh_client.py | 34 +++++ .../experimental/ops/_ops/switch_client.py | 39 ++++++ src/libtmux/experimental/ops/catalog.py | 8 +- .../facade/test_matrix_complete.py | 69 ++++++++++ 11 files changed, 509 insertions(+), 26 deletions(-) create mode 100644 src/libtmux/experimental/facade/client.py create mode 100644 src/libtmux/experimental/ops/_ops/detach_client.py create mode 100644 src/libtmux/experimental/ops/_ops/refresh_client.py create mode 100644 src/libtmux/experimental/ops/_ops/switch_client.py create mode 100644 tests/experimental/facade/test_matrix_complete.py diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/facade/__init__.py index c034c34a7..5d1a62943 100644 --- a/src/libtmux/experimental/facade/__init__.py +++ b/src/libtmux/experimental/facade/__init__.py @@ -2,38 +2,50 @@ The execution mode lives in the facade *type* (eager vs lazy vs async), so each method has one statically-known return type, while the operation definitions stay -shared. The facades form a small matrix over scope x mode: +shared. The matrix over scope x mode: -========== =========== ========== =========== -scope eager lazy async -========== =========== ========== =========== -server EagerServer -- -- -session EagerSession -- -- -window EagerWindow LazyWindow AsyncWindow -pane EagerPane LazyPane AsyncPane -========== =========== ========== =========== +========== ============ ============ ============ +scope eager lazy async +========== ============ ============ ============ +server EagerServer LazyServer AsyncServer +session EagerSession LazySession AsyncSession +window EagerWindow LazyWindow AsyncWindow +pane EagerPane LazyPane AsyncPane +client EagerClient LazyClient AsyncClient +========== ============ ============ ============ Eager handles execute immediately and return live handles; lazy handles record into a :class:`~..ops.plan.LazyPlan`; async handles await an :class:`~..engines.base.AsyncTmuxEngine`. "Control mode" is not a separate family --- any eager/async facade bound to a ``ControlModeEngine`` already uses it. See -issue 689 for the full matrix. +-- any eager/async facade bound to a ``ControlModeEngine`` already uses it. """ from __future__ import annotations +from libtmux.experimental.facade.client import AsyncClient, EagerClient, LazyClient from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane -from libtmux.experimental.facade.server import EagerServer -from libtmux.experimental.facade.session import EagerSession +from libtmux.experimental.facade.server import AsyncServer, EagerServer, LazyServer +from libtmux.experimental.facade.session import ( + AsyncSession, + EagerSession, + LazySession, +) from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow __all__ = ( + "AsyncClient", "AsyncPane", + "AsyncServer", + "AsyncSession", "AsyncWindow", + "EagerClient", "EagerPane", "EagerServer", "EagerSession", "EagerWindow", + "LazyClient", "LazyPane", + "LazyServer", + "LazySession", "LazyWindow", ) diff --git a/src/libtmux/experimental/facade/client.py b/src/libtmux/experimental/facade/client.py new file mode 100644 index 000000000..6cd280239 --- /dev/null +++ b/src/libtmux/experimental/facade/client.py @@ -0,0 +1,124 @@ +"""Client-scope facades (eager / lazy / async) over the operation spine. + +A client is a *view* (a terminal attachment keyed by name/tty), not part of the +ownership chain, but tmux exposes client-scoped commands -- ``detach-client``, +``switch-client``, ``refresh-client`` -- so it gets a facade like any other scope. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops import ( + DetachClient, + RefreshClient, + SwitchClient, + arun, + run, +) +from libtmux.experimental.ops._types import ClientName + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerClient: + """A live client handle; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> client = EagerClient(ConcreteEngine(), "/dev/pts/3") + >>> client.refresh().ok + True + >>> client.switch_to("$1").ok + True + """ + + engine: TmuxEngine + client_name: str + version: str | None = None + + def detach(self) -> Result: + """Detach this client.""" + return run( + DetachClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + def refresh(self) -> Result: + """Refresh this client.""" + return run( + RefreshClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + def switch_to(self, session_id: str) -> Result: + """Switch this client to a session.""" + return run( + SwitchClient(client=self.client_name, to_session=session_id), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyClient: + """A deferred client handle; methods record into a plan.""" + + plan: LazyPlan + client_name: str + + def detach(self) -> LazyClient: + """Record a detach; return self for chaining.""" + self.plan.add(DetachClient(target=ClientName(self.client_name))) + return self + + def refresh(self) -> LazyClient: + """Record a refresh; return self for chaining.""" + self.plan.add(RefreshClient(target=ClientName(self.client_name))) + return self + + def switch_to(self, session_id: str) -> LazyClient: + """Record a switch-client; return self for chaining.""" + self.plan.add(SwitchClient(client=self.client_name, to_session=session_id)) + return self + + +@dataclass(frozen=True) +class AsyncClient: + """An async live client handle: the eager client, awaited.""" + + engine: AsyncTmuxEngine + client_name: str + version: str | None = None + + async def detach(self) -> Result: + """Detach this client.""" + return await arun( + DetachClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + async def refresh(self) -> Result: + """Refresh this client.""" + return await arun( + RefreshClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + async def switch_to(self, session_id: str) -> Result: + """Switch this client to a session.""" + return await arun( + SwitchClient(client=self.client_name, to_session=session_id), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/server.py b/src/libtmux/experimental/facade/server.py index caf8de7a9..b10f6da7d 100644 --- a/src/libtmux/experimental/facade/server.py +++ b/src/libtmux/experimental/facade/server.py @@ -1,15 +1,20 @@ -"""Server-scope eager facade -- the entry point for live navigation.""" +"""Server-scope facades -- the entry points for facade navigation.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.session import EagerSession -from libtmux.experimental.ops import NewSession, run +from libtmux.experimental.facade.session import ( + AsyncSession, + EagerSession, + LazySession, +) +from libtmux.experimental.ops import NewSession, arun, run if t.TYPE_CHECKING: - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan @dataclass(frozen=True) @@ -23,8 +28,7 @@ class EagerServer: >>> session = server.new_session(name="work") >>> session.session_id '$1' - >>> window = session.new_window() - >>> pane = window.split() + >>> pane = session.new_window().split() >>> pane.pane_id '%1' """ @@ -54,3 +58,65 @@ def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer from libtmux.experimental.engines import SubprocessEngine return cls(SubprocessEngine.for_server(server), version=version) + + +@dataclass(frozen=True) +class LazyServer: + """A deferred server handle; records session creation into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> plan = LazyPlan() + >>> server = LazyServer(plan) + >>> session = server.new_session(name="work") + >>> _ = session.new_window(name="build") + >>> plan.execute(ConcreteEngine()).ok + True + """ + + plan: LazyPlan + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> LazySession: + """Record a new session; return a deferred session handle.""" + slot = self.plan.add( + NewSession(session_name=name, start_directory=start_directory), + ) + return LazySession(self.plan, slot) + + +@dataclass(frozen=True) +class AsyncServer: + """An async live server handle: the eager server, awaited.""" + + engine: AsyncTmuxEngine + version: str | None = None + + async def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> AsyncSession: + """Create a detached session; return a live async session handle.""" + result = await arun( + NewSession(session_name=name, start_directory=start_directory), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return AsyncSession(self.engine, result.new_id, self.version) + + @classmethod + def for_server(cls, server: t.Any, *, version: str | None = None) -> AsyncServer: + """Bind an async facade to a live :class:`libtmux.Server`'s socket.""" + from libtmux.experimental.engines import AsyncSubprocessEngine + + return cls(AsyncSubprocessEngine.for_server(server), version=version) diff --git a/src/libtmux/experimental/facade/session.py b/src/libtmux/experimental/facade/session.py index c2cad8c57..83cc0d40d 100644 --- a/src/libtmux/experimental/facade/session.py +++ b/src/libtmux/experimental/facade/session.py @@ -1,21 +1,24 @@ -"""Session-scope eager facade over the operation spine.""" +"""Session-scope facades (eager / lazy / async) over the operation spine.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.window import EagerWindow +from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow from libtmux.experimental.ops import ( KillSession, NewWindow, RenameSession, + arun, run, ) from libtmux.experimental.ops._types import SessionId if t.TYPE_CHECKING: - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan from libtmux.experimental.ops.results import Result @@ -73,3 +76,91 @@ def kill(self) -> Result: self.engine, version=self.version, ) + + +@dataclass(frozen=True) +class LazySession: + """A deferred session handle; methods record into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import SessionId + >>> plan = LazyPlan() + >>> session = LazySession(plan, SessionId("$0")) + >>> window = session.new_window(name="build") + >>> _ = session.rename("work") + >>> plan.execute(ConcreteEngine()).ok + True + """ + + plan: LazyPlan + ref: Target + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> LazyWindow: + """Record a new window; return a deferred window handle.""" + slot = self.plan.add( + NewWindow(target=self.ref, name=name, start_directory=start_directory), + ) + return LazyWindow(self.plan, slot) + + def rename(self, name: str) -> LazySession: + """Record a rename; return self for chaining.""" + self.plan.add(RenameSession(target=self.ref, name=name)) + return self + + def kill(self) -> LazySession: + """Record a kill; return self for chaining.""" + self.plan.add(KillSession(target=self.ref)) + return self + + +@dataclass(frozen=True) +class AsyncSession: + """An async live session handle: the eager session, awaited.""" + + engine: AsyncTmuxEngine + session_id: str + version: str | None = None + + async def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> AsyncWindow: + """Create a window in this session; return a live async window handle.""" + result = await arun( + NewWindow( + target=SessionId(self.session_id), + name=name, + start_directory=start_directory, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return AsyncWindow(self.engine, result.new_id, self.version) + + async def rename(self, name: str) -> Result: + """Rename this session.""" + return await arun( + RenameSession(target=SessionId(self.session_id), name=name), + self.engine, + version=self.version, + ) + + async def kill(self) -> Result: + """Kill this session.""" + return await arun( + KillSession(target=SessionId(self.session_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 75c986674..fd71445ba 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -22,6 +22,7 @@ from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._ops import ( CapturePane, + DetachClient, KillPane, KillSession, KillWindow, @@ -30,11 +31,13 @@ ListWindows, NewSession, NewWindow, + RefreshClient, RenameSession, RenameWindow, SelectLayout, SendKeys, SplitWindow, + SwitchClient, ) from libtmux.experimental.ops._types import ( ClientName, @@ -96,6 +99,7 @@ "CatalogEntry", "ClientName", "CreateResult", + "DetachClient", "DuplicateOperation", "Effects", "IndexRef", @@ -119,6 +123,7 @@ "OperationRegistry", "PaneId", "PlanResult", + "RefreshClient", "RenameSession", "RenameWindow", "Result", @@ -132,6 +137,7 @@ "SplitWindow", "SplitWindowResult", "Status", + "SwitchClient", "Target", "TmuxCommandError", "UnknownOperation", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 101f22aa5..61620c006 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -8,6 +8,7 @@ from __future__ import annotations from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.detach_client import DetachClient from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow @@ -16,14 +17,17 @@ from libtmux.experimental.ops._ops.list_windows import ListWindows from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.refresh_client import RefreshClient from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.split_window import SplitWindow +from libtmux.experimental.ops._ops.switch_client import SwitchClient __all__ = ( "CapturePane", + "DetachClient", "KillPane", "KillSession", "KillWindow", @@ -32,9 +36,11 @@ "ListWindows", "NewSession", "NewWindow", + "RefreshClient", "RenameSession", "RenameWindow", "SelectLayout", "SendKeys", "SplitWindow", + "SwitchClient", ) diff --git a/src/libtmux/experimental/ops/_ops/detach_client.py b/src/libtmux/experimental/ops/_ops/detach_client.py new file mode 100644 index 000000000..d12cf0897 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/detach_client.py @@ -0,0 +1,36 @@ +"""The ``detach-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class DetachClient(Operation[AckResult]): + """Detach a client. Produces no output (:class:`AckResult`). + + ``target`` is the client (a :class:`~.._types.ClientName`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> DetachClient(target=ClientName("/dev/pts/3")).render() + ('detach-client', '-t', '/dev/pts/3') + """ + + kind = "detach_client" + command = "detach-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target client.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/refresh_client.py b/src/libtmux/experimental/ops/_ops/refresh_client.py new file mode 100644 index 000000000..624d81d15 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/refresh_client.py @@ -0,0 +1,34 @@ +"""The ``refresh-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RefreshClient(Operation[AckResult]): + """Refresh a client. Produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> RefreshClient(target=ClientName("/dev/pts/3")).render() + ('refresh-client', '-t', '/dev/pts/3') + """ + + kind = "refresh_client" + command = "refresh-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target client.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/switch_client.py b/src/libtmux/experimental/ops/_ops/switch_client.py new file mode 100644 index 000000000..3938d9593 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/switch_client.py @@ -0,0 +1,39 @@ +"""The ``switch-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwitchClient(Operation[AckResult]): + """Switch a client to a session. Produces no output (:class:`AckResult`). + + Uses ``-c`` for the client and ``-t`` for the destination session, so it + does not use the generic target slot. + + Examples + -------- + >>> SwitchClient(client="/dev/pts/3", to_session="$1").render() + ('switch-client', '-c', '/dev/pts/3', '-t', '$1') + """ + + kind = "switch_client" + command = "switch-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + client: str + to_session: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-c -t ``.""" + return ("-c", self.client, "-t", self.to_session) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index b12f8d363..bd74aa2ca 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,10 +65,10 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'kill_pane', 'kill_session', 'kill_window', 'list_panes', - 'list_sessions', 'list_windows', 'new_session', 'new_window', - 'rename_session', 'rename_window', 'select_layout', 'send_keys', - 'split_window'] + ['capture_pane', 'detach_client', 'kill_pane', 'kill_session', 'kill_window', + 'list_panes', 'list_sessions', 'list_windows', 'new_session', 'new_window', + 'refresh_client', 'rename_session', 'rename_window', 'select_layout', + 'send_keys', 'split_window', 'switch_client'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/tests/experimental/facade/test_matrix_complete.py b/tests/experimental/facade/test_matrix_complete.py new file mode 100644 index 000000000..b5381a168 --- /dev/null +++ b/tests/experimental/facade/test_matrix_complete.py @@ -0,0 +1,69 @@ +"""Tests completing the facade matrix: lazy/async Server+Session and Client.""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.facade import ( + AsyncClient, + AsyncServer, + EagerClient, + LazyClient, + LazyServer, +) +from libtmux.experimental.ops import LazyPlan + + +def test_lazy_server_session_window_plan() -> None: + """LazyServer records a full Server->Session->Window creation plan.""" + plan = LazyPlan() + server = LazyServer(plan) + session = server.new_session(name="work") + window = session.new_window(name="build") + window.split() + assert len(plan) == 3 # new-session, new-window, split-window + + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert [r.created_id for r in outcome.results] == ["$1", "@1", "%1"] + + +def test_async_server_navigation() -> None: + """AsyncServer->AsyncSession->AsyncWindow navigation via await.""" + + async def main() -> str: + server = AsyncServer(AsyncConcreteEngine()) + session = await server.new_session(name="work") + window = await session.new_window() + pane = await window.split() + return pane.pane_id + + assert asyncio.run(main()) == "%1" + + +def test_eager_client_methods() -> None: + """EagerClient detach/refresh/switch_to return successful results.""" + client = EagerClient(ConcreteEngine(), "/dev/pts/3") + assert client.refresh().ok + assert client.switch_to("$1").ok + assert client.detach().ok + + +def test_lazy_client_records() -> None: + """LazyClient records client ops into a plan.""" + plan = LazyPlan() + client = LazyClient(plan, "/dev/pts/3") + client.refresh().switch_to("$1") + assert [op.kind for op in plan] == ["refresh_client", "switch_client"] + assert plan.execute(ConcreteEngine()).ok + + +def test_async_client() -> None: + """AsyncClient mirrors the eager client via await.""" + + async def main() -> bool: + client = AsyncClient(AsyncConcreteEngine(), "/dev/pts/3") + return (await client.refresh()).ok + + assert asyncio.run(main()) From 847be6f5229f423ab35670512b478fcfae3401fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:12:33 -0500 Subject: [PATCH 016/223] chore(deps[dev]): Add ty type checker + config why: The pre-commit gate now runs `uv run ty check`, so ty must be a configured dev tool. Brings the ty setup from the add-ty-type-checker branch and makes the experimental tree ty-clean. what: - add `ty` to the dev dependency group (uv.lock updated) - add [tool.ty] (environment py3.10, src=src/tests) with the documented rule ignores for known ty false positives, ported verbatim - fixes ty surfaced in experimental: Target is now a real union (ty rejects an implicit two-string type alias); OperationRegistry.list -> select so the `-> list[OpSpec]` return annotation is not shadowed by the method name --- pyproject.toml | 69 +++ src/libtmux/experimental/ops/_types.py | 3 +- src/libtmux/experimental/ops/catalog.py | 2 +- src/libtmux/experimental/ops/registry.py | 9 +- tests/experimental/ops/test_registry.py | 2 +- uv.lock | 539 +++++++++++------------ 6 files changed, 337 insertions(+), 287 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index efa9208a4..a474735c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ # Lint "ruff", "mypy", + "ty", ] docs = [ @@ -140,6 +141,74 @@ files = [ ] +[tool.ty.environment] +python-version = "3.10" + +[tool.ty.src] +include = ["src", "tests"] + +[tool.ty.rules] +# Private _pytest APIs (e.g. RaisesContext) are not publicly exported; +# both mypy and ty flag them. ty categorizes these as unresolved-import +# rather than attr-defined. +# https://github.com/astral-sh/ty/issues/1276 +unresolved-import = "ignore" +# ty resolves cmd() as a union type including a (Any, Any, /) -> tmux_cmd +# variant from CmdProtocol, causing false positives on *args methods. +# ty does not yet fully support argument unpacking (*args/**kwargs). +# https://github.com/astral-sh/ty/issues/404 +too-many-positional-arguments = "ignore" +# Same root cause as too-many-positional-arguments: ty cannot verify +# required params are present when calling with **kwargs unpacking. +# https://github.com/astral-sh/ty/issues/785 +missing-argument = "ignore" +# ty falls back to object.__init__ for union return types and +# dataclass-transform decorators, flagging all kwargs as unknown. +# https://github.com/astral-sh/ty/issues/2369 +unknown-argument = "ignore" +# Tests use monkeypatch.setattr(libtmux.common, ...) without explicit +# submodule import. The submodule is always available via __init__.py +# re-exports but ty cannot detect implicit registration. +# https://github.com/astral-sh/ty/issues/133 +possibly-missing-submodule = "ignore" +# Vendored version.py uses tuple comparison with mixed-type elements +# (int, str, InfinityType) for PEP 440 version ordering. ty cannot +# resolve element-wise comparison operators across union-typed tuples. +# https://github.com/astral-sh/ty/issues/1202 +unsupported-operator = "ignore" +# ty cannot verify argument types through **kwargs unpacking and +# narrows LiteralString to str differently than mypy. 20 false +# positives, mostly from **call_kwargs and **filter_expr patterns. +# https://github.com/astral-sh/ty/issues/785 +invalid-argument-type = "ignore" +# ty doesn't narrow through dict value iteration (item.split() in +# options.py) or union access patterns (Pane | None). 5 false +# positives where mypy correctly narrows the type. +unresolved-attribute = "ignore" +# ty can't see through isinstance narrowing for TypeVars (subscript +# assignment on _V in options.py) and IO[str] | None unions +# (control_mode.py, already suppressed for mypy). 4 false positives. +invalid-assignment = "ignore" +# Pane, Session, Window inherit from Obj which defines defaulted fields; +# subclasses add required server: Server. Python dataclass inheritance +# handles this at runtime but ty's analysis doesn't account for it. +dataclass-field-order = "ignore" +# re.search(rhs, data) in query_list.py where isinstance narrows both +# to str | bytes, but ty can't match the overload (str, str) | (bytes, +# Buffer) against (str | bytes, str | bytes). 2 false positives. +no-matching-overload = "ignore" +# options.py returns dict subscript typed as object where str | int | +# None expected; test_session.py returns MockTmuxCmd where tmux_cmd +# expected (already suppressed for mypy). 2 false positives. +invalid-return-type = "ignore" +# query_list.py b[key] where b is typed as object from a narrowing +# path ty can't follow (same context as unresolved-attribute). +not-subscriptable = "ignore" +# query_list.py filter_(k) where filter_ is a union including +# T@QueryList & Top[(...) -> object] — ty's intersection type +# resolution produces an uncallable Top type. +call-top-callable = "ignore" + [tool.coverage.run] branch = true parallel = true diff --git a/src/libtmux/experimental/ops/_types.py b/src/libtmux/experimental/ops/_types.py index 6fc387ff9..897052591 100644 --- a/src/libtmux/experimental/ops/_types.py +++ b/src/libtmux/experimental/ops/_types.py @@ -295,8 +295,7 @@ def render(self) -> str: Target: t.TypeAlias = ( - "PaneId | WindowId | SessionId | ClientName | NameRef | IndexRef " - "| Special | SlotRef" + PaneId | WindowId | SessionId | ClientName | NameRef | IndexRef | Special | SlotRef ) """The closed sum of everything that can appear as an operation ``-t`` target.""" diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index bd74aa2ca..9fd67aa3c 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -90,5 +90,5 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: effects=dataclasses.asdict(spec.effects), summary=_summary(spec.operation_cls.__doc__), ) - for spec in reg.list() + for spec in reg.select() ] diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index c410c1936..b85468195 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -149,12 +149,15 @@ def operation(self, kind: str) -> type[Operation[t.Any]]: """Return the operation class registered for ``kind``.""" return self.get(kind).operation_cls - def list( + def select( self, predicate: Callable[[OpSpec], bool] | None = None, ) -> list[OpSpec]: """Return registered specs (optionally filtered), sorted by ``kind``. + Named ``select`` rather than ``list`` so the ``-> list[OpSpec]`` return + annotation is not shadowed by the method name. + Parameters ---------- predicate : callable, optional @@ -163,7 +166,7 @@ def list( Examples -------- >>> from libtmux.experimental.ops import registry - >>> [s.kind for s in registry.list(lambda s: s.safety == "readonly")] + >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'list_panes', 'list_sessions', 'list_windows'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) @@ -181,7 +184,7 @@ def __contains__(self, kind: object) -> bool: def __iter__(self) -> Iterator[OpSpec]: """Iterate specs sorted by ``kind``.""" - return iter(self.list()) + return iter(self.select()) def __len__(self) -> int: """Return the number of registered operations.""" diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 0d6d4c766..6dbe407aa 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -43,7 +43,7 @@ def test_spec_from_operation_reads_classvars() -> None: def test_list_predicate_filters() -> None: """``list`` filters by a predicate and stays sorted by kind.""" readonly = [ - spec.kind for spec in registry.list(lambda spec: spec.safety == "readonly") + spec.kind for spec in registry.select(lambda spec: spec.safety == "readonly") ] assert readonly == ["capture_pane", "list_panes", "list_sessions", "list_windows"] diff --git a/uv.lock b/uv.lock index dd810c546..eeea9e458 100644 --- a/uv.lock +++ b/uv.lock @@ -53,16 +53,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.2" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] @@ -260,100 +260,100 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/83/6051c2a2feab48ae5bd27c84ef047191d2d4a3172f689e38eaa48ed17db1/coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67", size = 927640, upload-time = "2026-07-12T20:58:19.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/76/eef242d6bb95fb737fefd9bbf3a318897e4e82cf543b53a61c2a6e8588eb/coverage-7.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:05d87c2a43373ad6b976d0a99ad58c48633633bcdeb896dc645a006472cc4a71", size = 221195, upload-time = "2026-07-12T20:55:55.82Z" }, - { url = "https://files.pythonhosted.org/packages/89/73/41cd50da59d513a30fd3a34b5516b962ac17514defdb6705948446a5c0b1/coverage-7.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2afce82f2cf8f4c9002746a42755e1dc61baff33d9f7ab5569b4c9101f8f4d1d", size = 221714, upload-time = "2026-07-12T20:55:58.104Z" }, - { url = "https://files.pythonhosted.org/packages/22/9d/6df6ac6677719a087c839208186525b7b1f3653c50196c43246482ada65e/coverage-7.15.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a545ef5384d787d0fcac6c349afc2c5f99dcc39e13ed3c191b2c06305f64c04", size = 248454, upload-time = "2026-07-12T20:55:59.336Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f5/86f92c429fbf942986d01c3bb7b0b6c3ad06b09f7fa745023877413dde83/coverage-7.15.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:afaa144b8f5b3bc69fe0ce50d401c46b01ab264782553bfd05a3f98804524ecb", size = 250282, upload-time = "2026-07-12T20:56:00.589Z" }, - { url = "https://files.pythonhosted.org/packages/47/c6/99d0f1c1dd1583aaf8c1deca447e44426be54a76027a8c55e29970f22b15/coverage-7.15.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b6099490e5f88569c46b18605f556c3b30acc9a0a219cf7ef8fab8f7161ec4", size = 252149, upload-time = "2026-07-12T20:56:01.853Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c3/5d30740ec96f5fd8a659e46b6ee9224197f87aa66ce4a20e93dcf46221ac/coverage-7.15.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb5f6c2cf1ffd0bf2bd925c7cdcae9b4f208e9696d453ed51eb1f5fa0cc5b45b", size = 254061, upload-time = "2026-07-12T20:56:03.277Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/3afb2950673ca6cd22bc9ad6a9d6058c853bef8fe27a6d1df3adc274fd88/coverage-7.15.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81572011fc1fc271317da35da593944daef7bfd507085e35751abbe702b74f69", size = 249152, upload-time = "2026-07-12T20:56:04.618Z" }, - { url = "https://files.pythonhosted.org/packages/ca/53/25ab3c0a7cf517ed4d30cd4dd82331e005d4b57fec8c19c0ffd94fb50baa/coverage-7.15.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f765d13c08497687d0780cca66115c6aa4ba6703ad43b61e94fab9db689e3a3", size = 250187, upload-time = "2026-07-12T20:56:06Z" }, - { url = "https://files.pythonhosted.org/packages/ad/2a/8afb5f994e26e919e5dca5164f1c7b6b7ce6090aab1aac32b1ae1782f047/coverage-7.15.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:771caba880fee96493d18dfc465c318e08ab74e3bc2a3e4089e52514be6a6e54", size = 248193, upload-time = "2026-07-12T20:56:07.376Z" }, - { url = "https://files.pythonhosted.org/packages/d4/62/b6616e15288c9a7b628f7745e832fd8af2c03ec1f7dc11da25947f5ab16f/coverage-7.15.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:433a73200848e80f27712fc113b6ff5311f29b479a7d3bd4b1106138a77f9674", size = 252004, upload-time = "2026-07-12T20:56:08.787Z" }, - { url = "https://files.pythonhosted.org/packages/9e/21/5d80d65e4df3018dedb23a44f276f20ce26e429ffd76df03de03bcf9a767/coverage-7.15.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5d6fa45079db9fbeba0a69e3d91189f05301d6ac918162a53179d32fc9ed4910", size = 248463, upload-time = "2026-07-12T20:56:10.245Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/04a22708691561c44d77e1d5a60857b351310fdfbf253533780bb31c8b6f/coverage-7.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:00b6703c6640075cdce5124e9335dfdf9167272475301828acfdd09c0e5ee731", size = 249064, upload-time = "2026-07-12T20:56:11.614Z" }, - { url = "https://files.pythonhosted.org/packages/82/c9/a5c1e8954e657559de478acc1ef73e9393d3fc9b4a2ab4427501d765aca2/coverage-7.15.1-cp310-cp310-win32.whl", hash = "sha256:3ad9a0eac4728327fd870d52f74d2e631d176c5f178eaea2d9983ab5b9755a55", size = 223248, upload-time = "2026-07-12T20:56:13.068Z" }, - { url = "https://files.pythonhosted.org/packages/54/49/5f6566e14611a58e93a926f3a8a7b22e4e0702ebb4c467ac38f452c7f4cf/coverage-7.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:eb5fa75dc3d30e3a1b75da97973479b20ffa9b0641ff56d6e94b5f3e210daa54", size = 223874, upload-time = "2026-07-12T20:56:14.396Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/5095e07a0986930174fc153fd0bb115f7f2724f5344cc672e016d4f23a4e/coverage-7.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6506330b4a8dcf53b95bd84d8d0e817107cdb3fc1438e835029cdf0bc6612eb0", size = 221320, upload-time = "2026-07-12T20:56:16.036Z" }, - { url = "https://files.pythonhosted.org/packages/43/7a/7e2598ce98433a214020a61c0755d5961f9f26df0c630dc73e06b86d3416/coverage-7.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8a51a8ec382c39d939cba0ab07ae949077ae4e842343bd4eed22d432358cea9", size = 221823, upload-time = "2026-07-12T20:56:17.54Z" }, - { url = "https://files.pythonhosted.org/packages/8b/4f/67dac6a69139b490a239d91b6d5ef127982ffb89159769241656ab879ee5/coverage-7.15.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18ea20e3922d7f8ca9e0ef1084408d08c4ad62d5e531cb9c1f6896a99297ebea", size = 252242, upload-time = "2026-07-12T20:56:18.868Z" }, - { url = "https://files.pythonhosted.org/packages/05/37/5e439725a96de592309725550c8df639c359c209dcb4b152ed46032d4c0d/coverage-7.15.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aab9902a64b8390e3b56e539fddae1d79a267807fe5cb0c18d7d2f544ce867e2", size = 254153, upload-time = "2026-07-12T20:56:20.118Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1c/671ba9e15f9651a99962fb588a1fe4fb267cae4bb85f9bb4ac4413c6f7ef/coverage-7.15.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cc316264317b07a9e90d7f2b4188a15e36e9b54e651081b791b0515fa612a29", size = 256261, upload-time = "2026-07-12T20:56:21.682Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3c/88305322b4d015b4536b7174b8f9b87f850f01af9d89008cdbf984b65ff2/coverage-7.15.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d4e47e7eea81a8ccf060a07627654151d929da62c7b715738387c200905cec89", size = 258222, upload-time = "2026-07-12T20:56:22.962Z" }, - { url = "https://files.pythonhosted.org/packages/8d/20/d02e42333a57f266ded61ed4fb3821da1f2dc143734aaa3dcc9c117204c5/coverage-7.15.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6829d9a3b55ad2b73ef5fda8302e5be03683789e88b1a079dcf4a773229c21d", size = 252368, upload-time = "2026-07-12T20:56:24.475Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d0/94ebc010926a191d83e83b1f1236f8bd0d14d0eb98b5c324aa4be2589a8e/coverage-7.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:764e045811f9c8cda436641f3f088283351d331a519b5807f19041cd0a68da1c", size = 253953, upload-time = "2026-07-12T20:56:26.036Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a6/2c8553191da0c2da2c43ff294fb9bab57a5b0fae03560304a82a8b5addc3/coverage-7.15.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:09b3b088aa24489c4082bcc35fcc8224281ab94a653dfb6d3f0c8165b0d628ab", size = 252016, upload-time = "2026-07-12T20:56:27.374Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/20f47986d8360fa1a31d9858dd2ba0be009d44ecfa8d02120b1eb93c028b/coverage-7.15.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7911b02f57053adf8164ae63edb1c26574d24dfccabadc5268cf69310a69a358", size = 255783, upload-time = "2026-07-12T20:56:28.921Z" }, - { url = "https://files.pythonhosted.org/packages/17/36/fb61983b5259f78fa5e62ee74e91fe4de4c556fcbc9a3b9c9021c2f8b57b/coverage-7.15.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:664e279ed40599b8ed16f4db18d92a7e212c73129672bec8f5d96d4da48d2404", size = 251735, upload-time = "2026-07-12T20:56:30.465Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2e/d109d8d2d6c726ba2e78125297073918a2a91464f0ea777e5398fa3cf75c/coverage-7.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34fe7cf79d5f1f87f2e8ce7dc1c32950841f50e10d0120f263856acfad66de34", size = 252645, upload-time = "2026-07-12T20:56:32.076Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a0/5b3219cbb46135e14673427f2bf5890c4da4e6f655243692f3448aa3f42c/coverage-7.15.1-cp311-cp311-win32.whl", hash = "sha256:5e2d2536d2f57a354aa382ed303ac0e2e5c9522a508c05b998d26181b94163a7", size = 223414, upload-time = "2026-07-12T20:56:33.432Z" }, - { url = "https://files.pythonhosted.org/packages/5b/80/24e13ade0b6df8090e2492f9c55044bf1423f7ef28c76fc1af8c827a61cb/coverage-7.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:c337da8fca7ea93ab43f3868cfcde6cf6dad32c3906b273cfbad5d7390bc423b", size = 223888, upload-time = "2026-07-12T20:56:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9e/34659930ae380491af48e2f5f5f9a2e99cd9fb7de3d30f27be5ac5425137/coverage-7.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:db3403fdb7a94d5eb73e099befad8104d2a7d110a0f0d99df0de61c5d1fa756c", size = 223435, upload-time = "2026-07-12T20:56:36.302Z" }, - { url = "https://files.pythonhosted.org/packages/d9/76/32c1826309beaf4604c54accef108fdd611e5e5e93f2f5192f050cd5f6bd/coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80", size = 221497, upload-time = "2026-07-12T20:56:37.628Z" }, - { url = "https://files.pythonhosted.org/packages/db/5c/b88ce0d68fa550c7f3b58617fbf363bce64df5bf8295a01b627e4696e022/coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189", size = 221854, upload-time = "2026-07-12T20:56:39.033Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/8509fd2a66fc4e0a829f76a0f0b1dc3cc163368352435b5f243168658077/coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615", size = 253359, upload-time = "2026-07-12T20:56:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/e5/81/c7009ed7ea9765adb2b9d095054d748266fae5f07ac6c5f925f33715fcde/coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3", size = 256096, upload-time = "2026-07-12T20:56:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/21/52/dc8ee03968a5ba86e2da5aa48ddc9e3747bd65d63825fdce2d96acb9c5ff/coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304", size = 257211, upload-time = "2026-07-12T20:56:43.513Z" }, - { url = "https://files.pythonhosted.org/packages/b8/27/95d7623908da8937deb53d48efcdbf423907a47540e63c62fa21372c652b/coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c", size = 259473, upload-time = "2026-07-12T20:56:44.974Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3b/730d761928de97d585465680b568ae69622fb40716babadeabffe75cb51b/coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b", size = 253759, upload-time = "2026-07-12T20:56:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fc/6b9277acff1f9484b6c12857af5774689d1a6a95e13265f7405329d2f5da/coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61", size = 255131, upload-time = "2026-07-12T20:56:48.073Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f2/c704f86129594ba34e25a64695d2068c71d51c2b98907184d716c94f4aec/coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e", size = 253275, upload-time = "2026-07-12T20:56:49.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/29/80fee8af47de4a6dce71ccf2938491f444687a756af258a56d8469b8f1b0/coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5", size = 257345, upload-time = "2026-07-12T20:56:51.038Z" }, - { url = "https://files.pythonhosted.org/packages/20/21/a1e7d7ed1b48a8adf8fd5154d9e83fcc5ad8e6ff20ae00e44865057dce8d/coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d", size = 252844, upload-time = "2026-07-12T20:56:52.535Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8c/a4bc26e6ee207d412f3678f04d74be1550e83140563ca0e4997510579712/coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2", size = 254716, upload-time = "2026-07-12T20:56:53.968Z" }, - { url = "https://files.pythonhosted.org/packages/11/9d/8ad0266ecfada6353cf6627a1a02294cf55a907521b6ee0bd7b770cfd659/coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76", size = 223554, upload-time = "2026-07-12T20:56:55.583Z" }, - { url = "https://files.pythonhosted.org/packages/81/6d/24224929e06c6e05a93f738bc5f9e8e6ab658f8f1d9b823e7b85430e28b8/coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374", size = 224087, upload-time = "2026-07-12T20:56:57.041Z" }, - { url = "https://files.pythonhosted.org/packages/35/23/f81441dd01de88e53c97842e706907b307d9078918c3f4998b11e9ac7250/coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a", size = 223472, upload-time = "2026-07-12T20:56:58.594Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1e/6fa289d7993a2a39f1b283ddb58c4bfec80f7800be654b8ba8a9f6a07c63/coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a", size = 221519, upload-time = "2026-07-12T20:57:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e1/0db4902a0588234a70ab0218073c0b20fbc5c740aa35f91d360160a2ebc9/coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b", size = 221895, upload-time = "2026-07-12T20:57:01.867Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cb/3719783865092dac5e08df842730305ee9ab1973ae7ddb6fbdf27d401f30/coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e", size = 252882, upload-time = "2026-07-12T20:57:03.459Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5e/caf3abbdbb22629626160ffc9c017eb995b7cb11c0be46b974834cef1792/coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a", size = 255479, upload-time = "2026-07-12T20:57:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f1/d60f375bfe095fef944f0f19427aefdbf9bdd5a9571c41a4bf6e2f5fdb81/coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c", size = 256715, upload-time = "2026-07-12T20:57:06.446Z" }, - { url = "https://files.pythonhosted.org/packages/d7/17/8b0cbc90d02dc5adad4d9034c1824ec3fa567771b4c39d9c1e3f9b1431b8/coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90", size = 258845, upload-time = "2026-07-12T20:57:08.092Z" }, - { url = "https://files.pythonhosted.org/packages/92/29/c5e69f5fb75c322e9a3e4ef64d02eebfc3d66efceccc8514ff80a3c13a56/coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f", size = 253098, upload-time = "2026-07-12T20:57:09.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/57/21144252fdd0c01d707d48fbcea13a80b0b7c42ced3f299f885ab8978c3a/coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8", size = 254844, upload-time = "2026-07-12T20:57:11.141Z" }, - { url = "https://files.pythonhosted.org/packages/59/2a/499a28a322b0ce6768328e6c5bb2e2ad00ac068a7c7adb2ecd8533c8c5d9/coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a", size = 252807, upload-time = "2026-07-12T20:57:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/928a95da5da8b60f2b00e1482c7787b3316188e6d2d227fb8e124ada43a1/coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31", size = 256965, upload-time = "2026-07-12T20:57:14.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/10/889adbc1b8c9f866ed51e18a98bcafc0259fb9d29b81f50a719407c64ea8/coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad", size = 252628, upload-time = "2026-07-12T20:57:15.892Z" }, - { url = "https://files.pythonhosted.org/packages/1a/30/a5e1871e5d93416511f8e359d1ccebfe0cbb050a1bbf7dd20228533ec0cf/coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a", size = 254399, upload-time = "2026-07-12T20:57:17.703Z" }, - { url = "https://files.pythonhosted.org/packages/2d/26/c36fbffd549dadbdd1a75827528fb00a4c46aa3187b007b750b1e2cebbf2/coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e", size = 223564, upload-time = "2026-07-12T20:57:19.253Z" }, - { url = "https://files.pythonhosted.org/packages/16/fc/becbb9d2c4206d242b9b1e1e8e24a42f7926c0200dd3c788b9fab4bb96d5/coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7", size = 224106, upload-time = "2026-07-12T20:57:21.108Z" }, - { url = "https://files.pythonhosted.org/packages/d3/30/1cfc641461369b6858799fca61c0a8b5edc490c519bf7c636ffa6bbf556f/coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49", size = 223497, upload-time = "2026-07-12T20:57:22.734Z" }, - { url = "https://files.pythonhosted.org/packages/f0/46/81961952e7aebfb38ad0ae4264e8954cc607a7af9e7ac111f9fa986595cc/coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8", size = 221560, upload-time = "2026-07-12T20:57:24.282Z" }, - { url = "https://files.pythonhosted.org/packages/13/d2/ee14d715889f216baf47301d9f469e08fff6995552aaf67e897b282865f6/coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4", size = 221894, upload-time = "2026-07-12T20:57:25.87Z" }, - { url = "https://files.pythonhosted.org/packages/f3/38/f830bc6e6c2c5f23f43847125e6c650d378872f7eeba8d49f1d42193e8a9/coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81", size = 252938, upload-time = "2026-07-12T20:57:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/e1/53/0d3dd963631259d794c898735d5436e68d6a8d40749c419a07ff7c171469/coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca", size = 255445, upload-time = "2026-07-12T20:57:29.234Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fd/aabed228557565c958259251b89bab8c5669b31291fa63b3e2154ebb017a/coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74", size = 256790, upload-time = "2026-07-12T20:57:30.826Z" }, - { url = "https://files.pythonhosted.org/packages/bc/aa/1cc888e5d3623e603c4e5399653cb25728bb2b40d7519188a3e293d24620/coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047", size = 259104, upload-time = "2026-07-12T20:57:32.63Z" }, - { url = "https://files.pythonhosted.org/packages/5f/61/fc16d5f5e53098dae41efa21e8ccc611a9b4fe922750dd03dc56db552182/coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9", size = 252956, upload-time = "2026-07-12T20:57:34.316Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f3/52384668c3de4519ca770bf1975a89e4d6eb5aa2faf0da0577a14008cba4/coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652", size = 254797, upload-time = "2026-07-12T20:57:35.947Z" }, - { url = "https://files.pythonhosted.org/packages/ce/68/54b807e7c1868178e902fd8360b5d4e559394462f97285c50edf1c4608db/coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4", size = 252762, upload-time = "2026-07-12T20:57:37.856Z" }, - { url = "https://files.pythonhosted.org/packages/7b/48/dde8adf0338e3ace738757dccf1ce817e5fdcadfae77e1b48a77e5a3b265/coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c", size = 257037, upload-time = "2026-07-12T20:57:39.488Z" }, - { url = "https://files.pythonhosted.org/packages/07/f2/179dd88cf60a0aeeee16a970ffe250dccea8b80ed4beab4c5d3f6c41ad4b/coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633", size = 252577, upload-time = "2026-07-12T20:57:41.363Z" }, - { url = "https://files.pythonhosted.org/packages/2c/37/8a593d69ab521beb6a105a2017cac4ba94425ee0a8349e29c3c0b522d24f/coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41", size = 254235, upload-time = "2026-07-12T20:57:43.025Z" }, - { url = "https://files.pythonhosted.org/packages/6d/34/bc9b3bced66f2cdad4bf5e57ae51c54ea226e8aaaebfc9370a9a11877bf3/coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b", size = 223771, upload-time = "2026-07-12T20:57:44.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/4d20337bed61915d14349e62b88d5e4144d5a9872b64adbe90e9906db6db/coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a", size = 224257, upload-time = "2026-07-12T20:57:46.412Z" }, - { url = "https://files.pythonhosted.org/packages/7b/df/bbfeae4948f3ded516f92b32f2d57952427fc5ecfc0924487bb6ee6a5f38/coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74", size = 223683, upload-time = "2026-07-12T20:57:48.106Z" }, - { url = "https://files.pythonhosted.org/packages/35/65/0b431856064e387d1f5cf474625e4a0465e907024d42f35de6af19ced0be/coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b", size = 222298, upload-time = "2026-07-12T20:57:49.882Z" }, - { url = "https://files.pythonhosted.org/packages/a6/96/50eac9bd49df8a3df5f3d38746d1bf332299dffb554486c94ebd55c9dc49/coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49", size = 222561, upload-time = "2026-07-12T20:57:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5b/6ba1c4a27e10b8816fd2622b98162c83d3bdf1185097360373611bf96364/coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864", size = 263923, upload-time = "2026-07-12T20:57:53.392Z" }, - { url = "https://files.pythonhosted.org/packages/e4/59/fe03ade97a3ca2d890e98c572cf48a99fda9adba85757c34b823f41efe1e/coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c", size = 266043, upload-time = "2026-07-12T20:57:55.095Z" }, - { url = "https://files.pythonhosted.org/packages/16/e0/55c4b1217a572a43e13b39e1eb78d0da29fb23679003bd0cdf22c50b1978/coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07", size = 268465, upload-time = "2026-07-12T20:57:57.017Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/ee47944f76afc03909119b036fe9e0da8cbd274a5141287de79791a0fb6d/coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e", size = 269584, upload-time = "2026-07-12T20:57:58.958Z" }, - { url = "https://files.pythonhosted.org/packages/cb/8a/6b4d9779c7b2e21c3d12c3425e3261aa7411399319e27aa402dfec4db5d0/coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e", size = 263019, upload-time = "2026-07-12T20:58:00.979Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1e/db5c7fa0c8ba5ece390a1e1a3f30db71d440240a80589df28e66a7503c40/coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf", size = 265916, upload-time = "2026-07-12T20:58:03.005Z" }, - { url = "https://files.pythonhosted.org/packages/83/53/fe5176682b00709b13fab36addd26883139d0dea430816fea412e69255e2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82", size = 263520, upload-time = "2026-07-12T20:58:04.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e7/16f15127be93fbc70c667df5ec5dce934fc76c9b0888d84969a5d5341e2c/coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82", size = 267254, upload-time = "2026-07-12T20:58:06.824Z" }, - { url = "https://files.pythonhosted.org/packages/cd/73/e5119111f6f065376395a525f7ce6e9174d83f3db6d217ea0211a61cca4d/coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7", size = 262366, upload-time = "2026-07-12T20:58:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/d7/9c/6d0a81182df18a73b081e7a8630f0e2a52b12dfd7898c6ab839551a454d2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594", size = 264680, upload-time = "2026-07-12T20:58:10.359Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a2/bac0cbd4450638f1be2041a464b1766c8cc94abf705a2df6f1c8d4be870d/coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070", size = 224077, upload-time = "2026-07-12T20:58:12.065Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b2/d83c5403155172a43ba47c08641bad3f89822d8405102423a41339d2c857/coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f", size = 224908, upload-time = "2026-07-12T20:58:13.956Z" }, - { url = "https://files.pythonhosted.org/packages/cc/41/442b74cad832cc77712080585455482e7cc4f4a9a13192f65731dcd18231/coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b", size = 224219, upload-time = "2026-07-12T20:58:16.315Z" }, - { url = "https://files.pythonhosted.org/packages/34/98/07a67cf1a26e795d617ed5c540c042b0ac87b72f810c30c07f076cf334f3/coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c", size = 213284, upload-time = "2026-07-12T20:58:18.079Z" }, +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/97/c52dc440c390b6cfa87be9432b141a956e2d56d9b9f5fc8bd71c5f471722/coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", size = 220539, upload-time = "2026-07-02T13:08:19.252Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/602de8c2aec7e2e3e99ebfb8e04ba65598f746275396eea5f6794ff4673f/coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc", size = 221058, upload-time = "2026-07-02T13:08:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/fc/13/ebab0743138891c1d646d61e247ec29639afcbb6c4e1905e6a0f0c75291a/coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf", size = 247797, upload-time = "2026-07-02T13:08:22.474Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/b6ffb9e042aa48dc4144a8a65529affaec8dca0685309353614a2a7386ad/coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628", size = 249626, upload-time = "2026-07-02T13:08:23.803Z" }, + { url = "https://files.pythonhosted.org/packages/9c/06/243ff05b652333d8e3d060c11223efc2723b19cacf6605e433fa686ab5d4/coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6", size = 251493, upload-time = "2026-07-02T13:08:25.397Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2b/867faa17030a806114dae388b32a3fa929d8cd4bf39226fbc11f6e6bb705/coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91", size = 253406, upload-time = "2026-07-02T13:08:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/d789ce18f6605afc4895db75723424be2ef494282f77f61d8e5832923183/coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4", size = 248512, upload-time = "2026-07-02T13:08:28.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/b2673c30739f4a2e06649a0a38ad8b093c4d865462dc7bab0e9524a2c3b1/coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20", size = 249532, upload-time = "2026-07-02T13:08:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/acd79e9a41beabee92b623afe4f30b549916f48566271475f2907e752828/coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa", size = 247537, upload-time = "2026-07-02T13:08:31.173Z" }, + { url = "https://files.pythonhosted.org/packages/12/d4/2d301c4d1b3238d7c88b70ab9d13fd53ed9505662a7ff1b46ba1e2e4e3c3/coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1", size = 251348, upload-time = "2026-07-02T13:08:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/35/bb/c67708b2bc00f32e12805ec23d5fa677a0a51652f449341a89f9d6b1b715/coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd", size = 247806, upload-time = "2026-07-02T13:08:33.931Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/57c4f653c47a6e917748f8938e389e72fbcae44e3643cd906664f0477a13/coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90", size = 248410, upload-time = "2026-07-02T13:08:35.189Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/bb083041aef828903668f134273f319f2bd49224962875359c52faa5497f/coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0", size = 222588, upload-time = "2026-07-02T13:08:36.486Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/a09d8ee618956f626741b0734854bac4425a00e10c0565f5abca64e7e751/coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f", size = 223214, upload-time = "2026-07-02T13:08:37.885Z" }, + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [package.optional-dependencies] @@ -614,6 +614,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, + { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -662,6 +663,7 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a34" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a34" }, + { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -837,7 +839,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.3.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -847,52 +849,52 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, - { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, - { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, - { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, - { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, - { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, - { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, - { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, - { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, - { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, - { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, - { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, - { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, - { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, - { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, - { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, - { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, - { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, - { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, - { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, - { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, - { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, - { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, - { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e9/7e/be536678c6ae49ef058aba4b483d8c7bc104f471479016066f345bc1f5f8/mypy-2.2.0.tar.gz", hash = "sha256:2cdd99d48590dce6f6b7f1961eda75386364398fcdaad86923bc0f0231bf9baf", size = 3950939, upload-time = "2026-07-08T01:37:27.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/f1/a818c375094e20a74ec51444cce6979cedd61d476f860c2f084ea8653bd2/mypy-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97d21f3a1582ef30d0b77f9ca3ad88b4263f3c5aea9e7380cb5d7175160aec7e", size = 14903842, upload-time = "2026-07-08T01:33:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/15/f332590488a8e4bb4a24036484c8bc750fe01fbef09701451a8790ac4c44/mypy-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2529017942b951cc43db2e2712b71cc3aa7e9d90567630c03ed63d430aeec61a", size = 13902986, upload-time = "2026-07-08T01:36:59.24Z" }, + { url = "https://files.pythonhosted.org/packages/38/9f/7ae37860922d264b536790a3307caa195eb8abb733f2e81608229bbb0f07/mypy-2.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0823d7f273f3b4a49df7573eac271ddbf77a0e5f53e24d294a4f0f77ad22e1d7", size = 14132163, upload-time = "2026-07-08T01:34:16.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6d/25c31c85a793340a4a9be593f8fe2cea08fd7b615f8b602b3f52f2d822f6/mypy-2.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d78a0fc90704894bc9948d78affad14b882b08183a7c30412d185748aaa9418b", size = 15070110, upload-time = "2026-07-08T01:34:44.933Z" }, + { url = "https://files.pythonhosted.org/packages/cb/67/7adf1caa143f6a0d9c564de24e0c1ed48340185110c3b918adc2f447192b/mypy-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6efab25fa3568569fda06928743382900860c48be9200c1758ef5ce94b532714", size = 15371554, upload-time = "2026-07-08T01:34:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/49/cb/ceca928213076f540d9b068c3655c52d43549f124c1f6dece5ecb747bbeb/mypy-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:daeaf5287091d68f674c91416cabab0f80f36e347ed17fded38d7ebde682e9d1", size = 11063618, upload-time = "2026-07-08T01:36:13.235Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/08caa177a4f2e0c44b96d5ad85f2754761b45bb6d65841451ef5dc9edb86/mypy-2.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:f8d97940a0259e09c219903579720b2df12170c0c3a3b3799837c1fb63deb44e", size = 10070720, upload-time = "2026-07-08T01:37:13.251Z" }, + { url = "https://files.pythonhosted.org/packages/96/2e/1ea8028583fef6561d146b51d738d91268201313ecf69e603e808a740e74/mypy-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea6fe1a30f3e7dc574d641497ffead2428046b0f8bc5804d13b4e4392fdc317b", size = 14739569, upload-time = "2026-07-08T01:35:33.202Z" }, + { url = "https://files.pythonhosted.org/packages/ba/67/c0607da57d78358b3b4fbfa90ee8ca5c6bd1dbb997c9648904ad4d8861d8/mypy-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6965829a6d847a0925f51149472bbeb7a39332fb4801972fdfd9cedd9f8d43a4", size = 13821442, upload-time = "2026-07-08T01:33:45.991Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/0407007d4ec7a762bac20724af1f0a57a9d860186c39e73105b6b8396fa2/mypy-2.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a906bfc9c4c5de3ece6dc4726f8076d56ce9be1720baf0c6f84e926c10262a4", size = 14049813, upload-time = "2026-07-08T01:35:44.282Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/d718e006c8fed4bece7a1bebeea6dcd05f31433af552fc45a82fef426379/mypy-2.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91df92625cb3452758f27f4965b000fb05ad89b00c282cc3430a7bd6b0e5389e", size = 14978473, upload-time = "2026-07-08T01:36:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/82/03/dae7299c45a84efb1590875f92652a5beb2da98a2cff9a567995e2583fe4/mypy-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d42893d15894fd34f395090e2d78315ba7c637d5ee221683e02893f578c2f548", size = 15224649, upload-time = "2026-07-08T01:35:15.988Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8e/738e4e73d030f20852a81383c74319ca4a9a4fdaadc3a75823588fe2c833/mypy-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3abaeec02cdc72e30a147d99eb81852b6b745a2c8d19607e25241d79b1abbce", size = 11049851, upload-time = "2026-07-08T01:34:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/6c58caedb3fa5831a9b179687cd7dc252c66a61dca4800e5ba19c8eff82b/mypy-2.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:c39bdb7ceab252d15011e26d3a254b4aaa3bbf121b551febfa301df9b0c69abe", size = 10060307, upload-time = "2026-07-08T01:33:21.852Z" }, + { url = "https://files.pythonhosted.org/packages/d1/be/fbaba7b0ee89874fb11668416dec9e5585c190b676b0796cff26a9290fe8/mypy-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:484a2be712b245ac6e89847141f1f50c612b0a924aa25917e63e6cfcf4da07cd", size = 14928025, upload-time = "2026-07-08T01:37:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8f/f79a7c5a76671b0f563d4beaa7d99fe90df4500d2c1d2ba1be0432121bcf/mypy-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb0a020dc68480d40e484675558ed637140df1ccbf896a81ba68bca85f2b50a0", size = 13793027, upload-time = "2026-07-08T01:34:33.937Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b9/3db0086bab611d34e26061b86189e6f71de6d22a9b81699a93b006eabcf6/mypy-2.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc361340732ce7108fa0308812caf02bb6868f16112f1efd35bcad88badf3327", size = 14108322, upload-time = "2026-07-08T01:36:08.316Z" }, + { url = "https://files.pythonhosted.org/packages/58/29/4f1e13979a848de2a0fd385462354b58358b6e8b3d9661663e308f6e3d5d/mypy-2.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0179a3a0b833f724a65f22613607cf7ea941ab17ec34fa283f8d6dfe21d9fa9", size = 15190198, upload-time = "2026-07-08T01:36:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/14/f7/7759f6294d9d25d86671957d0974a215a2a24d429526e26a2f603de951c5/mypy-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e0899b13da1e4ba44b880550f247402ce90ffecc71c54b220bcbe7ecb34f394", size = 15424222, upload-time = "2026-07-08T01:33:39.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1b/05b212bef4d2234b5f0b551ea53ce0680d8075b2e79861c765f70b590945/mypy-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:511320b17467402e2906130e185abffffa3d7648aff1444fc2abb61f4c8a087d", size = 11135191, upload-time = "2026-07-08T01:35:03.019Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/495e7122f6589948b36d3820a046461906756a0eb1b6dedc13ebfec7815e/mypy-2.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:7589f370b33dcdd95708f5340f13a67c2c49140957f934b42ef63064d343cca0", size = 10132502, upload-time = "2026-07-08T01:34:04.508Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5f/2d7a9ac5646274cd6e77ce3abcc2a9ece760c2b21f4c4b9f301711e07855/mypy-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6968f27347ef539c443ddfd6897e79db525ddb8c856aa8fbf14c34f310ca5193", size = 14931618, upload-time = "2026-07-08T01:33:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8a/1adaa7caaa104f87021b1ac71252d62e646e9b623d77900ac7a0ae252bf3/mypy-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3df226d2a0ae2c3b03845af217800a68e2965d4b14914c99b78d3a2c8ae23299", size = 13857718, upload-time = "2026-07-08T01:35:27.555Z" }, + { url = "https://files.pythonhosted.org/packages/1d/15/b11586b5aebbb82213e297fc30a6fcf3bed6a9deea3739cd8dd87621f3fd/mypy-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc53553996aca2094216ad9306a6f06c5265d206c1bcb54dd367560bd3557825", size = 14059704, upload-time = "2026-07-08T01:33:57.363Z" }, + { url = "https://files.pythonhosted.org/packages/03/db/071e05ab442596bdf7a845e830d5ef7128a0175281038245b171a6b16873/mypy-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:996abf2f0bebf572556c60720e8dc0cf5292b64060fa68d7f2bc9caa48e01b6f", size = 15128719, upload-time = "2026-07-08T01:33:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/c4b84eafb85ee315da72471523cc1bf7d7c42164085c42333601da7a8817/mypy-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ec287c2381898c652bf8ff79448627fe1a9ee76d22005181fac7315a485c7108", size = 15378692, upload-time = "2026-07-08T01:34:10.468Z" }, + { url = "https://files.pythonhosted.org/packages/68/a4/59a0ee94877fdfe2958cec9b6add72a75393063c79cb60ab4026dd5e10c2/mypy-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2c967a7685fa93fcf1a778b3ebe76e756b28ba14655f539d7b61ff3da69352a", size = 11150911, upload-time = "2026-07-08T01:35:21.603Z" }, + { url = "https://files.pythonhosted.org/packages/90/e4/6a9144be50180ed43d8c92de9b03dff504daa92b5bcc0353e8960799a23b/mypy-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:d59a4b80351ec92e5f7415fcdd008bd77fcbefc7adf9cbc7ffe4eb9f71617734", size = 10125389, upload-time = "2026-07-08T01:36:36.164Z" }, + { url = "https://files.pythonhosted.org/packages/73/32/0aa8d8d197023ca6040f7b25a486cb47037b6350b0d3bae657c8f85fb43f/mypy-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1f6c3d76853071409ac58fc0aadfb276a22af5f190fdaa02152a858088a39ebd", size = 14926083, upload-time = "2026-07-08T01:36:02.365Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/35bbe0cb10e6699f90e988e537aaf4282a6c16e37f58848a242eb0a98bde/mypy-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bdaa177e80cc3292824d4ef3670b5b58771ee8d57c290e0c9c89e7968212332c", size = 13879985, upload-time = "2026-07-08T01:36:31.093Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0c/1597fbebd873e9b63452317740ae3dd32692cec5da180cc65acd96cd28cf/mypy-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80923e6d6e7878291f537ee11052f974954c20cb569798429a5dc265eb780b47", size = 14076883, upload-time = "2026-07-08T01:34:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/2ec021a83ec01b5d522639f78d8b36adade7fa4821db0f48fd6d82e861f3/mypy-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f24bd465a09077c8d64be8f19a6646db467a55490fd315fe7871afe6bb9645", size = 15103567, upload-time = "2026-07-08T01:36:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/53/3a/8cb3529f6d6800c7d069935e5c83a05d80263847b8a947cf6b0b16a9e958/mypy-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db34595464869f474708e769413d1d739fc33a69850f253757b9a4cc20bc1fec", size = 15354641, upload-time = "2026-07-08T01:36:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/320bc9a9553f8a9db5e847ec5ded762ef7ed7403c76c4ba2e8181c80e2f0/mypy-2.2.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b48092132c7b0ef4322773fecae62fc5b0bc339be348badeec8af502122a4a51", size = 7694355, upload-time = "2026-07-08T01:37:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/90/05/bf3b349e2f885cd3aab488111bb9049439c28bc028dac5073350d3df8fbe/mypy-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:6fc0e98b95e31755ca06d89f75fafa7820fbb3ea2caace6d83cba17625cd0acb", size = 11329146, upload-time = "2026-07-08T01:35:38.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/558b06e6cfe17ab88bb38f7b370b6bc68a74ba177c9e138db9748e422d2d/mypy-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:bc73a5b4d40e8a3e6b12ef82eb0c90430964e34016a36c2aff4e3bfe37ba41f0", size = 10316586, upload-time = "2026-07-08T01:36:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/0b/21/f0b96f19a9b8ba111a45ffbe9508e818b7f6990469b38f6888943f7bfd3a/mypy-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6257bd4b4c0ae2148548b869a1ff3758e38645b92c8fe65eca401866c3c551c1", size = 15922565, upload-time = "2026-07-08T01:35:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/f2/1dbcb20b0865d5e992541450a8c73f2fcc90f8bd7d8a4b81313e16934870/mypy-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dfa22b3ae862ac1ce76f5976ddd402651b5f090bcfd49c6d0484b8983a29eaf8", size = 14816515, upload-time = "2026-07-08T01:34:58.167Z" }, + { url = "https://files.pythonhosted.org/packages/84/a2/18cce9c7d5b4d14010d1f13836da11b234dda917b17ca8671fc32c136997/mypy-2.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a430bf26fe8cf372f3933fbd83e633d6561868803645a20e4e6d4523f52a3b", size = 15272246, upload-time = "2026-07-08T01:37:08.72Z" }, + { url = "https://files.pythonhosted.org/packages/ce/71/24d720c7924829bd675cbde2d0fa779f50abf676ca617f53d6a8bfef5fa7/mypy-2.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d58655a60e823b1a4c9ebcda072897fb0193c2f3e6f8e7c433e152aa4cb00233", size = 16226295, upload-time = "2026-07-08T01:34:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/b7/5e/785730990fc863ad8340b4ab44ac4ca23270aecff92c180ccdf27f9f5869/mypy-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d4452c955caf14e28bb046cbd0c3671272e6381630a8b81b0da9713558148890", size = 16493275, upload-time = "2026-07-08T01:35:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/33/55b1edf16f639f153972380d6977b81f65509c5b8f9c86b58b94b7990b03/mypy-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:68f5b7f7f755200f68c7181e3dfb28be9858162257690e539759c9f57721e388", size = 11749038, upload-time = "2026-07-08T01:35:50.071Z" }, + { url = "https://files.pythonhosted.org/packages/61/36/67424748a4e65e97f0e05bf00df379dfb6c2d817f82cc3a4ce5c96d99beb/mypy-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:78d201accfafce3801d978f2b8dbbd473a9ce364cc0a0dfd9192fe47d977e129", size = 10704254, upload-time = "2026-07-08T01:37:17.971Z" }, + { url = "https://files.pythonhosted.org/packages/28/cb/142c2097ca02c0d295b00625ff946808bdda65acda17d163c680d8a6a474/mypy-2.2.0-py3-none-any.whl", hash = "sha256:ecc138da861e932d1344214da4bae866b21900a9c2778824b51fe2fb47f5180e", size = 2726094, upload-time = "2026-07-08T01:34:00.075Z" }, ] [[package]] @@ -1166,27 +1168,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, - { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, - { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -1652,13 +1654,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "ty" +version = "0.0.57" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/16/d01c968d405acae51c07872e80f30f3a586235bdf52c9847ca0917a230a3/ty-0.0.57.tar.gz", hash = "sha256:bc058f564868690283a0420d09c269ec8be21e8e43b4b49ee975a17623092e44", size = 6100787, upload-time = "2026-07-08T11:33:59.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9c/a7948b05f2a3f43d511f88ef5c4f56d7edb8acc8caa5f56d7c5831f52c84/ty-0.0.57-py3-none-linux_armv6l.whl", hash = "sha256:cb6d3371dd8c78950b75bee31a36b94564a54a1f0eecafbbf05715ac1a5287d6", size = 11760058, upload-time = "2026-07-08T11:33:18.671Z" }, + { url = "https://files.pythonhosted.org/packages/6e/92/3776380decba3965bcaa1ea2b56f5b133aa3ef549bfbe4b79eb122dcc15d/ty-0.0.57-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e58b90491a48ec757bd50f512f3eb92c1c64b7d46a4db83677e9b60222e1d2bb", size = 11492105, upload-time = "2026-07-08T11:33:21.267Z" }, + { url = "https://files.pythonhosted.org/packages/13/be/912f8422d06fd1e29805a7c781e3ce4c821917e0e3d00f0cf176c0529469/ty-0.0.57-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dbb8207f75122c658ca21bf405cea8202e490240440461ae3e0c5a6f67ae668f", size = 11078675, upload-time = "2026-07-08T11:33:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/06/86/6ed526df554b491ce3d07bbec04f7a10304243bc994ab989352c85134b1e/ty-0.0.57-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:651243f391809de80b01be1729c5d0cecc7b952d7d22cfbf56f0b4f069703195", size = 11614379, upload-time = "2026-07-08T11:33:26.041Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/78af20f309abce31fa616e0142f85756e665a9965669b823181ff695ffec/ty-0.0.57-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:28c5392bbd7c4d6a4c1b1646a709231db675dc094f49bf71b7de83757125e93b", size = 11563854, upload-time = "2026-07-08T11:33:28.144Z" }, + { url = "https://files.pythonhosted.org/packages/88/dc/bf9231d5563b9e61a1eec9782184a4055afaa87259644cd9ed1376a7dc5c/ty-0.0.57-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d981535094ab492aaef6f71d9348bcf90e42e6de4cb50de2dd7fbabd8378360", size = 12229897, upload-time = "2026-07-08T11:33:30.262Z" }, + { url = "https://files.pythonhosted.org/packages/c0/dd/012008190a997097ebe500b9704baaad9135bfa033b9530a9b8f69f1d11f/ty-0.0.57-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:719b41fd6df61352866cad952d7bfb4873c893a70e806d37d0f1d30ad4f26cd5", size = 12816002, upload-time = "2026-07-08T11:33:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/0e42c361e38e04747ed2b3d3299512edd4ea6060d8e3124e3c6f2d2465a1/ty-0.0.57-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1641a609b025e13f44115c7071b39b02675044a78fbfedef239a5f7da50b393", size = 12323420, upload-time = "2026-07-08T11:33:35.426Z" }, + { url = "https://files.pythonhosted.org/packages/c6/01/18f1e03108d1ae8e515c92fb304994a66eaafd47037f928fd42fd3a1e29d/ty-0.0.57-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33f96a9dd6919fe8b1d3512cd99f16be4a51388f265de135fea2c2fbf0b4317", size = 12119481, upload-time = "2026-07-08T11:33:37.808Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/570b73fb3e32554ff03aa9f6650b33a504cdfa027a8b50a5ab087e56b20d/ty-0.0.57-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f0a1014a922b2b7f79a46e5cdbaa6feb7403a444631292b8666382a98a04de20", size = 12427299, upload-time = "2026-07-08T11:33:39.964Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/76b27f6a92e12525d09cd65d3c3b5aea419cf4782b13320db95ac3d310f0/ty-0.0.57-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d73aaed84023bf682819f871377b255fae9098898c50475426ac9bdfcd986872", size = 11562292, upload-time = "2026-07-08T11:33:42.392Z" }, + { url = "https://files.pythonhosted.org/packages/84/8b/d0c398147b00f336595e1a00a5895b3924251205c11b8d73cb0668281f1c/ty-0.0.57-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3e2104704063c00ab8a757af3e95378b32624a3e8d8149aad5251877bf82959", size = 11565833, upload-time = "2026-07-08T11:33:44.778Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/dab9745e977ef38751ec710f74e40d21fddbd04a80338dd5597c98899eed/ty-0.0.57-py3-none-musllinux_1_2_i686.whl", hash = "sha256:07ad760763646d8f1567ea5d899e6d217212acd8539b7afed5a370d93693553b", size = 11864967, upload-time = "2026-07-08T11:33:47.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/57/350812143b49dac7aa655f40a1f45d4821ca9b56b75d9b68d5e603269044/ty-0.0.57-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b4a6e1f0fee7df3e65fb0b9cbe9f99a4be39198558ed9aacc31a98d210ad7bcc", size = 12239054, upload-time = "2026-07-08T11:33:49.586Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d0/b3e3d9c6cce3debda6247aa435e81d18ec62fabfec13f35f6c6957066f47/ty-0.0.57-py3-none-win32.whl", hash = "sha256:f8d488c0535a8f0386dbe2c9bcb31d467ae0c68d0c9945113018937828ebaabb", size = 11225353, upload-time = "2026-07-08T11:33:52.569Z" }, + { url = "https://files.pythonhosted.org/packages/77/db/6ce240ee31413f9dc7467e31377553e92e2664252ee7d33aa9290a7a94bb/ty-0.0.57-py3-none-win_amd64.whl", hash = "sha256:de9529a7dcc3e529b08c14634dc4e7066ebea5cd55006afc02bde8033efe7c91", size = 12272419, upload-time = "2026-07-08T11:33:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/48662fa2c42289f309eeb8b5539cbe840e10002b65ac0700cb7889e92532/ty-0.0.57-py3-none-win_arm64.whl", hash = "sha256:7f3352777ce40c4906145f3c3e10b71716d8d35c0c9e3a0070d84f61dda0755f", size = 11686309, upload-time = "2026-07-08T11:33:57.246Z" }, +] + [[package]] name = "types-docutils" -version = "0.22.3.20260712" +version = "0.22.3.20260518" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/24/f50d49be6d5ebbae29ff83418b6788fc9897fdfb54d56555c9ae4b15fb53/types_docutils-0.22.3.20260712.tar.gz", hash = "sha256:bed54a50136c8e7613c03ee1c51eb958b42754915df83535de356b974ca05877", size = 57848, upload-time = "2026-07-12T05:13:36.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/91/81b520d0869a41aa0d86e713417b63e8604b4280c304bc09b02d74c7efd4/types_docutils-0.22.3.20260518.tar.gz", hash = "sha256:2c45ba63a9ac64246335359b68fe9c27602926499c9b67caec3780745f6aadee", size = 57504, upload-time = "2026-05-18T06:03:53.325Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/50/2b408d249cc7260b2296af68c395d90267c23288d1eb96765637eb6c1490/types_docutils-0.22.3.20260712-py3-none-any.whl", hash = "sha256:4bbc5cf949f8b1b1c7e1befa5a4d1c3bcad9f22f823538eea26ba7ff694fa38e", size = 91970, upload-time = "2026-07-12T05:13:35.141Z" }, + { url = "https://files.pythonhosted.org/packages/01/9b/243fb84ede4f987f303a89e734c5def35ead2f8bd962ad301c1e99680958/types_docutils-0.22.3.20260518-py3-none-any.whl", hash = "sha256:9c4cbc37d9e37f47dfe971ac09e9880380dc948ff1f23956892e810d0bf08676", size = 91970, upload-time = "2026-05-18T06:03:52.388Z" }, ] [[package]] @@ -1853,116 +1880,68 @@ wheels = [ [[package]] name = "websockets" -version = "16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/31/cd11d2796b95c93645bac8e396b0f4bac0896a07a7b87d473bfc359f02c3/websockets-16.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de72a9c611178b15557d98eabd3101c9663c4d68938510478a6d162f99afd213", size = 179772, upload-time = "2026-07-10T06:30:22.983Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/34306d802f9b599eab041688a2086318037560cfae616a860234cca575b6/websockets-16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:37b0e4d726ffea3776670092d3d13e1cb605076f036a695fd1259de0d9b9fe02", size = 177457, upload-time = "2026-07-10T06:30:24.636Z" }, - { url = "https://files.pythonhosted.org/packages/06/3a/36ebbb978a7af70ff952afe5b22561264967164e9ad68b6734cae94efeb4/websockets-16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:00d50c0a27098fcb7ab47b3d99a1b1159b534dbcd959fbf05113ebc37e5f927b", size = 177737, upload-time = "2026-07-10T06:30:25.954Z" }, - { url = "https://files.pythonhosted.org/packages/17/d7/944f341d0d3c0450ffd3d171479531df1818cb1df1623af4065113999c44/websockets-16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1acb698bff1da1782b31aebd8d7a24d7d05453964abcd7d03dbf6e25893908e8", size = 186244, upload-time = "2026-07-10T06:30:27.235Z" }, - { url = "https://files.pythonhosted.org/packages/32/e5/a9b98fc49ef0214718a9c839c6c63856a921877256ec46f371be32decfa8/websockets-16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc2c453f3b5f99c56b16e233aad5299860558487d26adb2ed27a00c14ca24b8c", size = 187484, upload-time = "2026-07-10T06:30:28.615Z" }, - { url = "https://files.pythonhosted.org/packages/ad/7a/a575b52ca090b1976ffbe4b5f0762d03f399dfcb48eab883101331be71a9/websockets-16.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1a9f08a0728b0835f1c6abe1d9b746ab3de49b7336a0e1919cf96be1e76273eb", size = 190143, upload-time = "2026-07-10T06:30:29.91Z" }, - { url = "https://files.pythonhosted.org/packages/7c/40/705fbbd5677242fd36f724e9a94103e6bbdcb7d71e8f4498bfc1a8a7d413/websockets-16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a089979d6173b27af18026c8d8b0077f83669a9169174482c4651e9f5739a5b6", size = 188004, upload-time = "2026-07-10T06:30:31.357Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0c/58227c8d66b1c4060c53bac8e066fb4fe2603060408e934f48660a448d72/websockets-16.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3c18dba232ec2b92a68579c9fed8ff5a18f853d1e09fc0b6ca3159e94f689fe", size = 186689, upload-time = "2026-07-10T06:30:32.712Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d0/5c1314782594aa347e0f18808ee277a61986a2a2f9f470df9893183995bd/websockets-16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c1eb7df4170d5068892a8834fb5c07b9552353deb0dbeb0bff3820481ae4792", size = 184559, upload-time = "2026-07-10T06:30:34.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e6/109c6f16850fd674b7e3d0e58b8987f05d3881abaa25f42a9faf5e85f097/websockets-16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c522bd48e625b6d557aa228967258d6d3da031c4cc21d3352fb302479aa9ba0a", size = 186997, upload-time = "2026-07-10T06:30:35.397Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ec/6afa1aebc59426438b85cf7a3868c53a89005e2250a648c99e99943b90a4/websockets-16.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d106396927a7f00b0f3a69215c3357f87bf0bca6844247121f7e8291e826a3b1", size = 185621, upload-time = "2026-07-10T06:30:36.88Z" }, - { url = "https://files.pythonhosted.org/packages/27/24/c038fe8682e9345bfa422d2cc5cc68b0491ab942c92e176bf8dfa6e8331f/websockets-16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d71bed12909b8039955536e192867d02d76cd3797cedfd0facf822e7668636c3", size = 187384, upload-time = "2026-07-10T06:30:38.096Z" }, - { url = "https://files.pythonhosted.org/packages/30/ca/dc0ef2be39c67394e24bc982a0af59cd6249bf2f4e4272813c5c505d0da9/websockets-16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9c1cf6f9a936b030b5bed0e800c5ee32069338129084546baf5ff5014dc62fa9", size = 185258, upload-time = "2026-07-10T06:30:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/17/6b/3ffecd83ca3404b41fbdf8e9b178e55b529cd59bf64ea08b5a37b616b568/websockets-16.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3fd3e6a7af2c8fcdcf4ffbeaf7f54a567b91a83267204187797f31faaa2a4efa", size = 186050, upload-time = "2026-07-10T06:30:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/75/26/2e068497c78f31591a610ab7ef6d8d383ecadbe98f9121e1ebda77ef6d2b/websockets-16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dddd27175bf640acae5561fa79b77e8ec71fc445816200523e5c19b6a556fb72", size = 186273, upload-time = "2026-07-10T06:30:42.309Z" }, - { url = "https://files.pythonhosted.org/packages/44/ab/4dc049cb2c9e1be3a2c6fef77118f9c5049979e99cd56a97759d2f40f980/websockets-16.1-cp310-cp310-win32.whl", hash = "sha256:cce36c80b3f2fede7942f1756d3d885fa6fa086766c8c1bcf00695ab80f0d51a", size = 180157, upload-time = "2026-07-10T06:30:43.565Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4f/5e010ce5f66a8e5df380843f704ada508195a021c0c8a0f933639c9ee1c0/websockets-16.1-cp310-cp310-win_amd64.whl", hash = "sha256:115fc4695b94bb855995b23fb1abcb66099a5995575d3d5bc5605a616c58d0eb", size = 180458, upload-time = "2026-07-10T06:30:45.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, - { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, - { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, - { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, - { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, - { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, - { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, - { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, - { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, - { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, - { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, - { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, - { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, - { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, - { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, - { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, - { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, - { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, - { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, - { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, - { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, - { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, - { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, - { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, - { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, - { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, - { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, - { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, - { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, - { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, - { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, - { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, - { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, - { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, - { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, - { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, - { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, - { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, - { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, - { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, - { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, - { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, - { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, - { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, - { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, - { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, - { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, - { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] From 88b65564c210a9f61879b72793e0be3fb9b6d1a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:22:39 -0500 Subject: [PATCH 017/223] Ops(feat): Add pluggable planners + {marked} fold why: Make lazy-plan dispatch strategy pluggable and A/B-testable, and add the chainable-commands {marked} lone-pane single-dispatch optimization the plain ;-fold lacked. what: - ops.planner: Planner Protocol + PlanStep; SequentialPlanner (one dispatch per op), FoldingPlanner (;-fold maximal chainable runs), MarkedPlanner (fold a pane creation + the chainable ops decorating its slot into one "split -P -F ; select-pane -m ; ... -t {marked} ; select-pane -M" dispatch) - _chain: render_marked / attribute_marked - LazyPlan.execute/aexecute take planner= (default SequentialPlanner), replacing fold=bool; _drive consumes the planner's PlanStep units and stays sans-I/O so sync and async share it - tests (NamedTuple + test_id): planner dispatch counts 3/2/1 with an identical PlanResult, marked single-dispatch rendering + fallback, and a live {marked} fold against a real tmux server --- src/libtmux/experimental/ops/__init__.py | 12 ++ src/libtmux/experimental/ops/_chain.py | 58 ++++++++- src/libtmux/experimental/ops/plan.py | 91 +++++++------- src/libtmux/experimental/ops/planner.py | 133 +++++++++++++++++++++ tests/experimental/ops/test_chain.py | 7 +- tests/experimental/ops/test_planner.py | 146 +++++++++++++++++++++++ 6 files changed, 401 insertions(+), 46 deletions(-) create mode 100644 src/libtmux/experimental/ops/planner.py create mode 100644 tests/experimental/ops/test_planner.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index fd71445ba..13a224b18 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -66,6 +66,13 @@ from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.plan import LazyPlan, PlanResult +from libtmux.experimental.ops.planner import ( + FoldingPlanner, + MarkedPlanner, + Planner, + PlanStep, + SequentialPlanner, +) from libtmux.experimental.ops.registry import ( OperationRegistry, OpSpec, @@ -102,6 +109,7 @@ "DetachClient", "DuplicateOperation", "Effects", + "FoldingPlanner", "IndexRef", "KillPane", "KillSession", @@ -113,6 +121,7 @@ "ListSessionsResult", "ListWindows", "ListWindowsResult", + "MarkedPlanner", "NameRef", "NewSession", "NewWindow", @@ -123,6 +132,8 @@ "OperationRegistry", "PaneId", "PlanResult", + "PlanStep", + "Planner", "RefreshClient", "RenameSession", "RenameWindow", @@ -131,6 +142,7 @@ "Scope", "SelectLayout", "SendKeys", + "SequentialPlanner", "SessionId", "SlotRef", "Special", diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 0d62b12cb..7561047ba 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -16,9 +16,11 @@ from __future__ import annotations +import dataclasses import typing as t from dataclasses import dataclass +from libtmux.experimental.ops._types import Special from libtmux.experimental.ops.exc import OperationError if t.TYPE_CHECKING: @@ -92,13 +94,65 @@ def attribute( return results +def render_marked( + create: Operation[t.Any], + decorates: Sequence[Operation[t.Any]], + version: str | None = None, +) -> tuple[str, ...]: + r"""Render a pane creation + its decorates as one ``{marked}`` invocation. + + Emits `` ; select-pane -m ; + ... ; select-pane -M``: the new pane is marked, every decorate addresses it + through tmux's ``{marked}`` register, and the mark is cleared at the end. + """ + parts: list[tuple[str, ...]] = [ + create.render(version=version), + ("select-pane", "-m"), + ] + parts.extend( + dataclasses.replace(op, target=Special("{marked}")).render(version=version) + for op in decorates + ) + parts.append(("select-pane", "-M")) + out: list[str] = [] + for index, part in enumerate(parts): + if index: + out.append(";") + out.extend(_escape_arg(token) for token in part) + return tuple(out) + + +def attribute_marked( + create: Operation[t.Any], + decorates: Sequence[Operation[t.Any]], + merged: CommandResult, + version: str | None = None, +) -> tuple[Result, list[Result], str | None]: + """Split a ``{marked}`` dispatch result into the create's + decorates' results.""" + new_id = merged.stdout[0].strip() if merged.stdout else None + if new_id is not None: + create_result = create.build_result( + returncode=0, stdout=(new_id,), version=version + ) + else: + create_result = create.build_result( + returncode=merged.returncode or 1, + stderr=tuple(merged.stderr), + version=version, + ) + # Attribute over the {marked}-retargeted decorates -- their original SlotRef + # target is unresolved and cannot render. + marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] + return create_result, attribute(marked, merged, version), new_id + + @dataclass(frozen=True) class OpChain: """An ordered group of operations composed with :meth:`~.Operation.then`. A power-user, inspectable handle for explicit chaining. Add it to a - :class:`~.plan.LazyPlan` with :meth:`~.plan.LazyPlan.add_chain`; the plan's - folded execution (``execute(fold=True)``) batches chainable runs anyway. + :class:`~.plan.LazyPlan` with :meth:`~.plan.LazyPlan.add_chain`; a folding + planner (``execute(planner=FoldingPlanner())``) batches chainable runs anyway. Examples -------- diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index d408dbe13..74bec9639 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -19,7 +19,12 @@ from dataclasses import dataclass, field from libtmux.experimental.engines.base import CommandRequest -from libtmux.experimental.ops._chain import attribute, render_chain +from libtmux.experimental.ops._chain import ( + attribute, + attribute_marked, + render_chain, + render_marked, +) from libtmux.experimental.ops._types import ( PaneId, SessionId, @@ -29,6 +34,7 @@ ) from libtmux.experimental.ops.exc import OperationError from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.planner import Planner, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict if t.TYPE_CHECKING: @@ -182,46 +188,49 @@ def add_chain(self, chain: OpChain) -> None: def _drive( self, version: str | None, - fold: bool, + planner: Planner, ) -> Generator[_Single | _Chain, t.Any, PlanResult]: - """Sans-I/O resolution core. + """Sans-I/O resolution core driven by a :class:`~.planner.Planner`. - Yields a :class:`_Single` (driver runs one op and returns its - :class:`~.results.Result`) or, when ``fold`` is set, a :class:`_Chain` - for a maximal run of chainable ops (driver returns the merged - :class:`~..engines.base.CommandResult`, attributed per op here). The - sync and async drivers differ only in ``run`` vs ``await arun`` and + Yields a :class:`_Single` (driver runs one op, returns its + :class:`~.results.Result`) or a :class:`_Chain` (driver returns the + merged :class:`~..engines.base.CommandResult`, attributed per op here). + The sync and async drivers differ only in ``run`` vs ``await arun`` and ``engine.run`` vs ``await engine.run``. """ bindings: dict[int, str] = {} results: dict[int, Result] = {} - index = 0 - total = len(self._operations) - while index < total: - operation = self._operations[index] - if fold and operation.chainable: - indices: list[int] = [] - group: list[Operation[t.Any]] = [] - cursor = index - while cursor < total and self._operations[cursor].chainable: - indices.append(cursor) - group.append(_resolve(self._operations[cursor], bindings)) - cursor += 1 - if len(group) == 1: - results[indices[0]] = yield _Single(group[0]) - else: - merged: CommandResult = yield _Chain(render_chain(group, version)) - results.update( - zip(indices, attribute(group, merged, version), strict=True), - ) - index = cursor - else: - result = yield _Single(_resolve(operation, bindings)) + for step in planner.plan(self._operations): + if step.marked: + create_idx, *decorate_idx = step.indices + create = _resolve(self._operations[create_idx], bindings) + decorates = [self._operations[i] for i in decorate_idx] + merged: CommandResult = yield _Chain( + render_marked(create, decorates, version), + ) + created, decorated, new_id = attribute_marked( + create, + decorates, + merged, + version, + ) + results[create_idx] = created + results.update(zip(decorate_idx, decorated, strict=True)) + if new_id is not None: + bindings[create_idx] = new_id + elif len(step.indices) == 1: + index = step.indices[0] + result = yield _Single(_resolve(self._operations[index], bindings)) results[index] = result if result.created_id is not None: bindings[index] = result.created_id - index += 1 - ordered = tuple(results[slot] for slot in range(total)) + else: + group = [_resolve(self._operations[i], bindings) for i in step.indices] + merged = yield _Chain(render_chain(group, version)) + results.update( + zip(step.indices, attribute(group, merged, version), strict=True), + ) + ordered = tuple(results[slot] for slot in range(len(self._operations))) return PlanResult(ordered, bindings) def execute( @@ -229,17 +238,17 @@ def execute( engine: TmuxEngine, *, version: str | None = None, - fold: bool = False, + planner: Planner | None = None, ) -> PlanResult: """Resolve and execute the plan synchronously. - With ``fold=True``, maximal runs of chainable operations dispatch as one - ``tmux a ; b`` invocation (fewer forks / one control-mode command), - attributing a typed result per operation. ``fold`` defaults off because - folding changes observable semantics (fewer dispatches; a folded - failure marks the first member failed and the rest skipped). + The *planner* decides dispatch grouping; it defaults to + :class:`~.planner.SequentialPlanner` (one tmux call per op). Pass a + :class:`~.planner.FoldingPlanner` or :class:`~.planner.MarkedPlanner` to + fold dispatches -- the :class:`PlanResult` is identical, only the + dispatch count changes. """ - gen = self._drive(version, fold) + gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: @@ -263,10 +272,10 @@ async def aexecute( engine: AsyncTmuxEngine, *, version: str | None = None, - fold: bool = False, + planner: Planner | None = None, ) -> PlanResult: """Resolve and execute the plan asynchronously (same resolution core).""" - gen = self._drive(version, fold) + gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py new file mode 100644 index 000000000..928dfabe8 --- /dev/null +++ b/src/libtmux/experimental/ops/planner.py @@ -0,0 +1,133 @@ +"""Pluggable planners that decide how a lazy plan dispatches. + +A planner is pure policy: given the recorded operations it returns a list of +:class:`PlanStep` units, and :meth:`~.plan.LazyPlan.execute` runs them. Swapping +planners changes *how many tmux dispatches* a plan costs without changing its +result, so strategies can be A/B-tested (same :class:`~.plan.PlanResult`, +differing dispatch count). + +- :class:`SequentialPlanner` -- one dispatch per operation (the safe default). +- :class:`FoldingPlanner` -- fold maximal runs of chainable ops into one + ``tmux a ; b`` dispatch. +- :class:`MarkedPlanner` -- additionally fold a pane creation plus the chainable + ops that decorate it into a *single* dispatch via tmux's ``{marked}`` register + (the chainable-commands lone-pane optimization). +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import SlotRef + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.ops.operation import Operation + + +@dataclass(frozen=True) +class PlanStep: + """One dispatch unit. + + A single op (``len(indices) == 1``), a ``;``-folded chain (more, ``marked`` + false), or a ``{marked}`` fold (``marked`` true: ``indices[0]`` is the pane + creation, the rest decorate it through ``{marked}``). + """ + + indices: tuple[int, ...] + marked: bool = False + + +@t.runtime_checkable +class Planner(t.Protocol): + """Decides the dispatch units for a plan's operations.""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Return the ordered dispatch units for *operations*.""" + ... + + +class SequentialPlanner: + """Dispatch each operation on its own (one tmux call per op).""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """One single-op step per operation.""" + return [PlanStep((index,)) for index in range(len(operations))] + + +def _fold_runs(operations: Sequence[Operation[t.Any]], start: int) -> list[PlanStep]: + """Group maximal runs of chainable ops from *start* into chain/single steps.""" + steps: list[PlanStep] = [] + index = start + total = len(operations) + while index < total: + if operations[index].chainable: + cursor = index + while cursor < total and operations[cursor].chainable: + cursor += 1 + steps.append(PlanStep(tuple(range(index, cursor)))) + index = cursor + else: + steps.append(PlanStep((index,))) + index += 1 + return steps + + +class FoldingPlanner: + """Fold maximal runs of chainable ops into one ``;`` dispatch each.""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Chain consecutive chainable ops; dispatch the rest alone.""" + return _fold_runs(operations, 0) + + +class MarkedPlanner: + """Fold a pane creation + the chainable ops that decorate it into one call. + + When a pane-creating op (``effects.creates == "pane"``) is immediately + followed by chainable ops that target *its* slot, they collapse into a single + ``split-window … ; select-pane -m ; … -t {marked} … ; select-pane -M`` + dispatch. Anything else folds like :class:`FoldingPlanner`. + """ + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Emit ``{marked}`` folds where possible, else fold normally.""" + steps: list[PlanStep] = [] + index = 0 + total = len(operations) + while index < total: + decorates = _marked_decorates(operations, index) + if decorates: + steps.append(PlanStep((index, *decorates), marked=True)) + index = decorates[-1] + 1 + else: + run = _fold_runs(operations, index)[0] + steps.append(run) + index = run.indices[-1] + 1 + return steps + + +def _marked_decorates( + operations: Sequence[Operation[t.Any]], + index: int, +) -> tuple[int, ...]: + """Return the indices of chainable ops decorating a pane created at *index*. + + Empty unless *index* is a pane creation followed by at least one chainable op + whose target is that creation's :class:`SlotRef`. + """ + creator = operations[index] + if creator.effects.creates != "pane" or creator.chainable: + return () + decorates: list[int] = [] + cursor = index + 1 + while cursor < len(operations): + op = operations[cursor] + if op.chainable and op.target == SlotRef(index): + decorates.append(cursor) + cursor += 1 + else: + break + return tuple(decorates) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 635713da2..66501299a 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -9,6 +9,7 @@ from libtmux.experimental.engines import CommandResult from libtmux.experimental.ops import ( CapturePane, + FoldingPlanner, KillWindow, LazyPlan, OpChain, @@ -97,7 +98,7 @@ def test_fold_dispatches_once() -> None: plan.add(KillWindow(target=WindowId("@2"))) engine = _CountingEngine() - outcome = plan.execute(engine, fold=True) + outcome = plan.execute(engine, planner=FoldingPlanner()) assert len(engine.calls) == 1 # all three folded into one ';' dispatch assert ";" in engine.calls[0] @@ -125,7 +126,7 @@ def test_fold_failure_attributes_first_failed_rest_skipped() -> None: plan.add(KillWindow(target=WindowId("@2"))) engine = _CountingEngine(returncode=1, stderr=("boom",)) - outcome = plan.execute(engine, fold=True) + outcome = plan.execute(engine, planner=FoldingPlanner()) assert [r.status for r in outcome.results] == ["failed", "skipped", "skipped"] assert not outcome.ok @@ -139,7 +140,7 @@ def test_fold_keeps_creation_ops_unfolded() -> None: plan.add(RenameWindow(target=WindowId("@1"), name="x")) # chainable from libtmux.experimental.engines import ConcreteEngine - outcome = plan.execute(ConcreteEngine(), fold=True) + outcome = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) # split resolved the pane id; the send-keys folded with rename, retargeted assert outcome.results[1].argv[:3] == ("send-keys", "-t", "%1") diff --git a/tests/experimental/ops/test_planner.py b/tests/experimental/ops/test_planner.py new file mode 100644 index 000000000..8595982a2 --- /dev/null +++ b/tests/experimental/ops/test_planner.py @@ -0,0 +1,146 @@ +"""Tests for pluggable planners and the {marked} fold. + +Planners must produce the same PlanResult while differing only in dispatch +count -- the property that makes them A/B-testable. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + MarkedPlanner, + SendKeys, + SequentialPlanner, + SplitWindow, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.ops.planner import Planner + from libtmux.session import Session + + +class _CountingEngine: + """Engine that counts dispatches and echoes a fabricated pane id.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + self._pane = 0 + + def run(self, request: CommandRequest) -> CommandResult: + """Record argv; fabricate a pane id when an id is captured.""" + from libtmux.experimental.engines.base import CommandResult + + self.calls.append(request.args) + stdout: tuple[str, ...] = () + if "-F" in request.args and "#{pane_id}" in request.args: + self._pane += 1 + stdout = (f"%{self._pane}",) + return CommandResult(cmd=("tmux", *request.args), stdout=stdout, returncode=0) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def _build_plan() -> LazyPlan: + """Split a window, then decorate the new pane (the {marked}-foldable shape).""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + plan.add(SendKeys(target=pane, keys=":w", enter=True)) + return plan + + +class PlannerCase(t.NamedTuple): + """One planner and the dispatch count it should produce for the plan.""" + + test_id: str + planner: Planner + dispatches: int + + +PLANNER_CASES = ( + PlannerCase(test_id="sequential", planner=SequentialPlanner(), dispatches=3), + PlannerCase(test_id="folding", planner=FoldingPlanner(), dispatches=2), + PlannerCase(test_id="marked", planner=MarkedPlanner(), dispatches=1), +) + + +@pytest.mark.parametrize( + list(PlannerCase._fields), + PLANNER_CASES, + ids=[c.test_id for c in PLANNER_CASES], +) +def test_planner_dispatch_count( + test_id: str, + planner: Planner, + dispatches: int, +) -> None: + """Each planner produces the expected number of tmux dispatches.""" + engine = _CountingEngine() + _build_plan().execute(engine, planner=planner) + assert len(engine.calls) == dispatches + + +def test_planners_agree_on_result() -> None: + """Different planners yield the same per-op result (status + new pane id).""" + + def outcome(planner: Planner) -> tuple[list[str], str | None]: + result = _build_plan().execute(_CountingEngine(), planner=planner) + first = result.results[0] + return [r.status for r in result.results], first.created_id + + sequential = outcome(SequentialPlanner()) + assert outcome(FoldingPlanner()) == sequential + assert outcome(MarkedPlanner()) == sequential + assert sequential == (["complete", "complete", "complete"], "%1") + + +def test_marked_renders_single_dispatch() -> None: + """The {marked} fold issues split + mark + decorates + unmark in one call.""" + engine = _CountingEngine() + _build_plan().execute(engine, planner=MarkedPlanner()) + (argv,) = engine.calls + assert "#{pane_id}" in argv # split captures the new pane id + assert "-m" in argv and "-M" in argv # mark set then cleared + assert "{marked}" in argv # decorates target the marked register + + +def test_marked_falls_back_without_pattern() -> None: + """A non-creator chainable run still folds (no {marked} shape required).""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(SendKeys(target=PaneId("%1"), keys="b")) + engine = _CountingEngine() + plan.execute(engine, planner=MarkedPlanner()) + assert len(engine.calls) == 1 # folded as a plain ; chain + + +def test_marked_fold_live(session: Session) -> None: + """The {marked} fold creates and decorates a real pane in one dispatch.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + window = session.active_window + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId(window.window_id))) + plan.add(SendKeys(target=pane, keys="echo marked", enter=True)) + + outcome = plan.execute(engine, planner=MarkedPlanner()) + + assert outcome.ok + new_id = outcome.results[0].created_id + assert new_id is not None + assert server.panes.get(pane_id=new_id) is not None From f7099380ac8d1e75ef245fb96f3fd2e4edefc700 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:35:34 -0500 Subject: [PATCH 018/223] Ops(feat): Add non-list read operations why: The read seam only covered the list-* family, leaving common queries (existence, format evaluation, option dumps, attached clients) outside the typed operation/result model. what: - Add has-session, display-message, show-options, list-clients ops, each rendering inert argv and parsing tmux output into a typed result - Add HasSessionResult.exists, DisplayMessageResult.text, ShowOptionsResult.options, ListClientsResult.clients result types - Add ClientSnapshot model (a leaf view, not part of the tree) - has-session maps rc != 0 to exists=False (a valid answer, not failure) - Wire ops/results/snapshot exports; update enumerating doctests/tests - Add test_read_breadth.py (NamedTuple + test_id render/parse/round-trip cases plus live tmux coverage) --- src/libtmux/experimental/models/__init__.py | 2 + src/libtmux/experimental/models/snapshots.py | 39 ++++ src/libtmux/experimental/ops/__init__.py | 16 ++ src/libtmux/experimental/ops/_ops/__init__.py | 8 + .../experimental/ops/_ops/display_message.py | 65 ++++++ .../experimental/ops/_ops/has_session.py | 65 ++++++ .../experimental/ops/_ops/list_clients.py | 70 ++++++ .../experimental/ops/_ops/show_options.py | 87 +++++++ src/libtmux/experimental/ops/catalog.py | 7 +- src/libtmux/experimental/ops/registry.py | 3 +- src/libtmux/experimental/ops/results.py | 39 ++++ tests/experimental/ops/test_read_breadth.py | 220 ++++++++++++++++++ tests/experimental/ops/test_registry.py | 11 +- 13 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/display_message.py create mode 100644 src/libtmux/experimental/ops/_ops/has_session.py create mode 100644 src/libtmux/experimental/ops/_ops/list_clients.py create mode 100644 src/libtmux/experimental/ops/_ops/show_options.py create mode 100644 tests/experimental/ops/test_read_breadth.py diff --git a/src/libtmux/experimental/models/__init__.py b/src/libtmux/experimental/models/__init__.py index 82866f450..9f07540f9 100644 --- a/src/libtmux/experimental/models/__init__.py +++ b/src/libtmux/experimental/models/__init__.py @@ -11,6 +11,7 @@ from __future__ import annotations from libtmux.experimental.models.snapshots import ( + ClientSnapshot, PaneSnapshot, ServerSnapshot, SessionSnapshot, @@ -18,6 +19,7 @@ ) __all__ = ( + "ClientSnapshot", "PaneSnapshot", "ServerSnapshot", "SessionSnapshot", diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py index 9a8e4e808..7e2f1b5e3 100644 --- a/src/libtmux/experimental/models/snapshots.py +++ b/src/libtmux/experimental/models/snapshots.py @@ -118,6 +118,45 @@ def from_dict(cls, data: Mapping[str, t.Any]) -> PaneSnapshot: return cls.from_format(data["fields"]) +@dataclass(frozen=True) +class ClientSnapshot: + """An immutable snapshot of one attached tmux client. + + A client is a view (a terminal attachment), not part of the ownership tree, + so it is a leaf snapshot. + + Examples + -------- + >>> client = ClientSnapshot.from_format({ + ... "client_name": "/dev/pts/3", "client_tty": "/dev/pts/3", + ... "client_session": "$0", "client_pid": "4242", + ... }) + >>> client.name, client.session, client.pid + ('/dev/pts/3', '$0', 4242) + """ + + name: str = "" + tty: str | None = None + session: str = "" + pid: int | None = None + width: int | None = None + height: int | None = None + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> ClientSnapshot: + """Build a client snapshot from a raw tmux format mapping.""" + return cls( + name=raw.get("client_name", ""), + tty=raw.get("client_tty"), + session=raw.get("client_session", ""), + pid=_as_int(raw.get("client_pid")), + width=_as_int(raw.get("client_width")), + height=_as_int(raw.get("client_height")), + fields=dict(raw), + ) + + @dataclass(frozen=True) class WindowSnapshot: """An immutable snapshot of one tmux window and its panes. diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 13a224b18..273799e7e 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -23,9 +23,12 @@ from libtmux.experimental.ops._ops import ( CapturePane, DetachClient, + DisplayMessage, + HasSession, KillPane, KillSession, KillWindow, + ListClients, ListPanes, ListSessions, ListWindows, @@ -36,6 +39,7 @@ RenameWindow, SelectLayout, SendKeys, + ShowOptions, SplitWindow, SwitchClient, ) @@ -83,10 +87,14 @@ AckResult, CapturePaneResult, CreateResult, + DisplayMessageResult, + HasSessionResult, + ListClientsResult, ListPanesResult, ListSessionsResult, ListWindowsResult, Result, + ShowOptionsResult, SplitWindowResult, status_for, ) @@ -107,14 +115,20 @@ "ClientName", "CreateResult", "DetachClient", + "DisplayMessage", + "DisplayMessageResult", "DuplicateOperation", "Effects", "FoldingPlanner", + "HasSession", + "HasSessionResult", "IndexRef", "KillPane", "KillSession", "KillWindow", "LazyPlan", + "ListClients", + "ListClientsResult", "ListPanes", "ListPanesResult", "ListSessions", @@ -144,6 +158,8 @@ "SendKeys", "SequentialPlanner", "SessionId", + "ShowOptions", + "ShowOptionsResult", "SlotRef", "Special", "SplitWindow", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 61620c006..9bbc8a1f5 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -9,9 +9,12 @@ from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.detach_client import DetachClient +from libtmux.experimental.ops._ops.display_message import DisplayMessage +from libtmux.experimental.ops._ops.has_session import HasSession from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.list_clients import ListClients from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows @@ -22,15 +25,19 @@ from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.split_window import SplitWindow from libtmux.experimental.ops._ops.switch_client import SwitchClient __all__ = ( "CapturePane", "DetachClient", + "DisplayMessage", + "HasSession", "KillPane", "KillSession", "KillWindow", + "ListClients", "ListPanes", "ListSessions", "ListWindows", @@ -41,6 +48,7 @@ "RenameWindow", "SelectLayout", "SendKeys", + "ShowOptions", "SplitWindow", "SwitchClient", ) diff --git a/src/libtmux/experimental/ops/_ops/display_message.py b/src/libtmux/experimental/ops/_ops/display_message.py new file mode 100644 index 000000000..68821960a --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/display_message.py @@ -0,0 +1,65 @@ +"""The ``display-message -p`` operation -- a typed format query.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import DisplayMessageResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class DisplayMessage(Operation[DisplayMessageResult]): + """Evaluate a tmux format and print it (``display-message -p``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> DisplayMessage(target=PaneId("%1"), message="#{pane_width}").render() + ('display-message', '-t', '%1', '-p', '#{pane_width}') + >>> DisplayMessage(message="#{pane_id}").build_result( + ... returncode=0, stdout=("%1",) + ... ).text + '%1' + """ + + kind = "display_message" + command = "display-message" + scope = "pane" + result_cls = DisplayMessageResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, reads_output=True, idempotent=True) + + message: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-p ``.""" + return ("-p", self.message) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> DisplayMessageResult: + """Expose the printed line as :attr:`~.DisplayMessageResult.text`.""" + return DisplayMessageResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + text=stdout[0] if stdout else "", + ) diff --git a/src/libtmux/experimental/ops/_ops/has_session.py b/src/libtmux/experimental/ops/_ops/has_session.py new file mode 100644 index 000000000..0c236763e --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/has_session.py @@ -0,0 +1,65 @@ +"""The ``has-session`` operation -- a typed existence query.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import HasSessionResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class HasSession(Operation[HasSessionResult]): + """Check whether a session exists (``has-session``). + + ``target`` is the session. A missing session is a valid answer (rc 1), not + an error, so the result is always ``complete`` and carries the answer in + :attr:`~.HasSessionResult.exists`. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> HasSession(target=SessionId("$0")).render() + ('has-session', '-t', '$0') + >>> HasSession(target=SessionId("$0")).build_result(returncode=1).exists + False + """ + + kind = "has_session" + command = "has-session" + scope = "session" + result_cls = HasSessionResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target.""" + return () + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> HasSessionResult: + """Map the exit code to existence; the query itself always completes.""" + return HasSessionResult( + operation=self, + argv=argv, + status="complete", + returncode=returncode, + stdout=stdout, + stderr=stderr, + exists=returncode == 0, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_clients.py b/src/libtmux/experimental/ops/_ops/list_clients.py new file mode 100644 index 000000000..dfc1023f1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_clients.py @@ -0,0 +1,70 @@ +"""The ``list-clients`` operation -- typed client snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListClientsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListClients(Operation[ListClientsResult]): + """List attached clients and return typed snapshots. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListClients(), ConcreteEngine(), version="3.6a").clients + () + """ + + kind = "list_clients" + command = "list-clients" + scope = "server" + result_cls = ListClientsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the ``-F`` format template.""" + _fields, fmt = get_output_format( + "list-clients", version or DEFAULT_LIST_VERSION + ) + return ("-F", fmt) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListClientsResult: + """Parse each output row into a client format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-clients", ver) for line in stdout if line) + return ListClientsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/show_options.py b/src/libtmux/experimental/ops/_ops/show_options.py new file mode 100644 index 000000000..fae7f71d6 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/show_options.py @@ -0,0 +1,87 @@ +"""The ``show-options`` operation -- typed option pairs.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ShowOptionsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ShowOptions(Operation[ShowOptionsResult]): + """Show options as ``name value`` pairs (``show-options``). + + Parameters + ---------- + global_, server, window : bool + Scope flags (``-g`` / ``-s`` / ``-w``). + include_inherited : bool + Include inherited options (``-A``). + + Examples + -------- + >>> ShowOptions(global_=True).render() + ('show-options', '-g') + >>> ShowOptions().build_result( + ... returncode=0, stdout=("status on", "history-limit 2000") + ... ).options["history-limit"] + '2000' + """ + + kind = "show_options" + command = "show-options" + scope = "session" + result_cls = ShowOptionsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + global_: bool = False + server: bool = False + window: bool = False + include_inherited: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the scope/inheritance flags.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.server: + out.append("-s") + if self.window: + out.append("-w") + if self.include_inherited: + out.append("-A") + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ShowOptionsResult: + """Parse ``name value`` lines into a mapping.""" + options: dict[str, str] = {} + for line in stdout: + name, _, value = line.partition(" ") + options[name] = value + return ShowOptionsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + options=options, + ) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 9fd67aa3c..35484094a 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,10 +65,11 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'detach_client', 'kill_pane', 'kill_session', 'kill_window', - 'list_panes', 'list_sessions', 'list_windows', 'new_session', 'new_window', + ['capture_pane', 'detach_client', 'display_message', 'has_session', + 'kill_pane', 'kill_session', 'kill_window', 'list_clients', 'list_panes', + 'list_sessions', 'list_windows', 'new_session', 'new_window', 'refresh_client', 'rename_session', 'rename_window', 'select_layout', - 'send_keys', 'split_window', 'switch_client'] + 'send_keys', 'show_options', 'split_window', 'switch_client'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index b85468195..f610606b6 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -167,7 +167,8 @@ def select( -------- >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] - ['capture_pane', 'list_panes', 'list_sessions', 'list_windows'] + ['capture_pane', 'display_message', 'has_session', 'list_clients', + 'list_panes', 'list_sessions', 'list_windows', 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index adddb3a18..3f4073ba8 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -20,6 +20,7 @@ from dataclasses import dataclass, field from libtmux.experimental.models.snapshots import ( + ClientSnapshot, PaneSnapshot, ServerSnapshot, SessionSnapshot, @@ -280,3 +281,41 @@ class ListSessionsResult(Result): def sessions(self) -> tuple[SessionSnapshot, ...]: """One typed session snapshot per row.""" return tuple(SessionSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class ListClientsResult(Result): + """Result of a ``list-clients`` operation (typed :attr:`clients`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def clients(self) -> tuple[ClientSnapshot, ...]: + """One typed client snapshot per row.""" + return tuple(ClientSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class HasSessionResult(Result): + """Result of a ``has-session`` existence query. + + ``has-session`` exits ``0`` when the session exists and nonzero otherwise -- + a valid answer, not a failure -- so this result is always ``complete`` and + carries the answer in :attr:`exists`. + """ + + exists: bool = False + + +@dataclass(frozen=True) +class DisplayMessageResult(Result): + """Result of ``display-message -p``: the formatted :attr:`text`.""" + + text: str = "" + + +@dataclass(frozen=True) +class ShowOptionsResult(Result): + """Result of ``show-options``: parsed ``name value`` pairs in :attr:`options`.""" + + options: Mapping[str, str] = field(default_factory=dict) diff --git a/tests/experimental/ops/test_read_breadth.py b/tests/experimental/ops/test_read_breadth.py new file mode 100644 index 000000000..276ff9052 --- /dev/null +++ b/tests/experimental/ops/test_read_breadth.py @@ -0,0 +1,220 @@ +"""Tests for the non-list read ops (has-session/display-message/show-options). + +These cover the read seam beyond the ``list-*`` family: a typed existence +query, a format evaluation, an option dump, and the client listing. Each op +renders an inert argv and parses tmux output into a typed result without a live +server; live tests then exercise them against real tmux. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + DisplayMessage, + HasSession, + ListClients, + ShowOptions, + result_from_dict, + result_to_dict, +) +from libtmux.experimental.ops._types import NameRef, PaneId, SessionId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the argv fragments its render must contain.""" + + test_id: str + op: Operation[t.Any] + fragments: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="has_session", + op=HasSession(target=SessionId("$0")), + fragments=("has-session", "-t", "$0"), + ), + RenderCase( + test_id="display_message", + op=DisplayMessage(target=PaneId("%1"), message="#{pane_id}"), + fragments=("display-message", "-t", "%1", "-p", "#{pane_id}"), + ), + RenderCase( + test_id="show_options_global", + op=ShowOptions(global_=True), + fragments=("show-options", "-g"), + ), + RenderCase( + test_id="show_options_server_inherited", + op=ShowOptions(server=True, include_inherited=True), + fragments=("show-options", "-s", "-A"), + ), + RenderCase( + test_id="list_clients", + op=ListClients(), + fragments=("list-clients", "-F"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_read_op_render( + test_id: str, + op: Operation[t.Any], + fragments: tuple[str, ...], +) -> None: + """Each read op renders the expected argv fragments.""" + argv = op.render(version="3.2a") + for fragment in fragments: + assert fragment in argv + + +class ParseCase(t.NamedTuple): + """An op plus a synthesized tmux outcome and the result fields it yields.""" + + test_id: str + op: Operation[t.Any] + returncode: int + stdout: tuple[str, ...] + expected: dict[str, t.Any] + + +PARSE_CASES = ( + ParseCase( + test_id="has_session_exists", + op=HasSession(target=SessionId("$0")), + returncode=0, + stdout=(), + expected={"exists": True, "status": "complete"}, + ), + ParseCase( + test_id="has_session_missing", + op=HasSession(target=SessionId("$9")), + returncode=1, + stdout=(), + expected={"exists": False, "status": "complete"}, + ), + ParseCase( + test_id="display_message_text", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=("%1",), + expected={"text": "%1"}, + ), + ParseCase( + test_id="display_message_empty", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=(), + expected={"text": ""}, + ), + ParseCase( + test_id="show_options_pairs", + op=ShowOptions(), + returncode=0, + stdout=("status on", "history-limit 2000"), + expected={"options": {"status": "on", "history-limit": "2000"}}, + ), +) + + +@pytest.mark.parametrize( + list(ParseCase._fields), + PARSE_CASES, + ids=[c.test_id for c in PARSE_CASES], +) +def test_read_op_parse( + test_id: str, + op: Operation[t.Any], + returncode: int, + stdout: tuple[str, ...], + expected: dict[str, t.Any], +) -> None: + """Each read op parses its tmux output into the expected result fields.""" + result = op.build_result(returncode=returncode, stdout=stdout) + for attr, value in expected.items(): + assert getattr(result, attr) == value + + +@pytest.mark.parametrize( + list(ParseCase._fields), + PARSE_CASES, + ids=[c.test_id for c in PARSE_CASES], +) +def test_read_result_round_trip( + test_id: str, + op: Operation[t.Any], + returncode: int, + stdout: tuple[str, ...], + expected: dict[str, t.Any], +) -> None: + """Every read result round-trips through its JSON-friendly dict form.""" + result = op.build_result(returncode=returncode, stdout=stdout) + assert result_from_dict(result_to_dict(result)) == result + + +def test_has_session_live(session: Session) -> None: + """has-session answers True for the fixture session, False for a fake one.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + assert session.session_id is not None + + present = run(HasSession(target=SessionId(session.session_id)), engine) + assert present.status == "complete" + assert present.exists is True + + absent = run(HasSession(target=NameRef("no-such-session-xyz")), engine) + assert absent.status == "complete" + assert absent.exists is False + + +def test_display_message_live(session: Session) -> None: + """display-message -p evaluates a format against a real pane.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + result = run( + DisplayMessage(target=PaneId(pane.pane_id), message="#{session_id}"), + engine, + ) + assert result.ok + assert result.text == session.session_id + + +def test_show_options_live(session: Session) -> None: + """show-options -g returns a non-empty option mapping.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + result = run(ShowOptions(global_=True), engine) + assert result.ok + assert result.options # global options are always present + + +def test_list_clients_live(session: Session) -> None: + """list-clients returns typed client snapshots (possibly none).""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + result = run(ListClients(), engine) + assert result.ok + assert all(c.name for c in result.clients) diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 6dbe407aa..667656f25 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -45,7 +45,16 @@ def test_list_predicate_filters() -> None: readonly = [ spec.kind for spec in registry.select(lambda spec: spec.safety == "readonly") ] - assert readonly == ["capture_pane", "list_panes", "list_sessions", "list_windows"] + assert readonly == [ + "capture_pane", + "display_message", + "has_session", + "list_clients", + "list_panes", + "list_sessions", + "list_windows", + "show_options", + ] def test_register_duplicate_fails_closed() -> None: From 045c884af24b8b05aa4dcf322c21816136b329d4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:47:47 -0500 Subject: [PATCH 019/223] Ops(feat): Add pane mutation/creation operations why: The operation surface lacked the pane verbs the ORM relies on (select/resize/swap/break/join/move/respawn/pipe/clear-history), blocking pane-level parity for engine-driven callers. what: - Add select-pane, last-pane, resize-pane, respawn-pane, pipe-pane, clear-history (single-target) ops - Add swap-pane, join-pane, move-pane (dual-target) and break-pane (creates a window, captures #{window_id} into CreateResult) - Add src_target field + src_args() helper on Operation for the -s source of dual-target commands; serialize handles src_target like target - Wire ops/exports; extend the catalog kind-enumeration doctest - Add test_pane_ops.py (NamedTuple + test_id render/round-trip cases plus live tmux coverage) --- src/libtmux/experimental/ops/__init__.py | 20 ++ src/libtmux/experimental/ops/_ops/__init__.py | 20 ++ .../experimental/ops/_ops/break_pane.py | 89 ++++++++ .../experimental/ops/_ops/clear_history.py | 30 +++ .../experimental/ops/_ops/join_pane.py | 66 ++++++ .../experimental/ops/_ops/last_pane.py | 32 +++ .../experimental/ops/_ops/move_pane.py | 27 +++ .../experimental/ops/_ops/pipe_pane.py | 61 ++++++ .../experimental/ops/_ops/resize_pane.py | 65 ++++++ .../experimental/ops/_ops/respawn_pane.py | 64 ++++++ .../experimental/ops/_ops/select_pane.py | 70 ++++++ .../experimental/ops/_ops/swap_pane.py | 63 ++++++ src/libtmux/experimental/ops/catalog.py | 12 +- src/libtmux/experimental/ops/operation.py | 19 ++ src/libtmux/experimental/ops/serialize.py | 8 +- tests/experimental/ops/test_pane_ops.py | 207 ++++++++++++++++++ 16 files changed, 844 insertions(+), 9 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/break_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/clear_history.py create mode 100644 src/libtmux/experimental/ops/_ops/join_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/last_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/move_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/pipe_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/resize_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/respawn_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/select_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/swap_pane.py create mode 100644 tests/experimental/ops/test_pane_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 273799e7e..fa229bef8 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -21,26 +21,36 @@ from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._ops import ( + BreakPane, CapturePane, + ClearHistory, DetachClient, DisplayMessage, HasSession, + JoinPane, KillPane, KillSession, KillWindow, + LastPane, ListClients, ListPanes, ListSessions, ListWindows, + MovePane, NewSession, NewWindow, + PipePane, RefreshClient, RenameSession, RenameWindow, + ResizePane, + RespawnPane, SelectLayout, + SelectPane, SendKeys, ShowOptions, SplitWindow, + SwapPane, SwitchClient, ) from libtmux.experimental.ops._types import ( @@ -109,9 +119,11 @@ __all__ = ( "AckResult", + "BreakPane", "CapturePane", "CapturePaneResult", "CatalogEntry", + "ClearHistory", "ClientName", "CreateResult", "DetachClient", @@ -123,9 +135,11 @@ "HasSession", "HasSessionResult", "IndexRef", + "JoinPane", "KillPane", "KillSession", "KillWindow", + "LastPane", "LazyPlan", "ListClients", "ListClientsResult", @@ -136,6 +150,7 @@ "ListWindows", "ListWindowsResult", "MarkedPlanner", + "MovePane", "NameRef", "NewSession", "NewWindow", @@ -145,16 +160,20 @@ "OperationError", "OperationRegistry", "PaneId", + "PipePane", "PlanResult", "PlanStep", "Planner", "RefreshClient", "RenameSession", "RenameWindow", + "ResizePane", + "RespawnPane", "Result", "Safety", "Scope", "SelectLayout", + "SelectPane", "SendKeys", "SequentialPlanner", "SessionId", @@ -165,6 +184,7 @@ "SplitWindow", "SplitWindowResult", "Status", + "SwapPane", "SwitchClient", "Target", "TmuxCommandError", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 9bbc8a1f5..4b54007fd 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -7,48 +7,68 @@ from __future__ import annotations +from libtmux.experimental.ops._ops.break_pane import BreakPane from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.clear_history import ClearHistory from libtmux.experimental.ops._ops.detach_client import DetachClient from libtmux.experimental.ops._ops.display_message import DisplayMessage from libtmux.experimental.ops._ops.has_session import HasSession +from libtmux.experimental.ops._ops.join_pane import JoinPane from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.last_pane import LastPane from libtmux.experimental.ops._ops.list_clients import ListClients from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows +from libtmux.experimental.ops._ops.move_pane import MovePane from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.pipe_pane import PipePane from libtmux.experimental.ops._ops.refresh_client import RefreshClient from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow +from libtmux.experimental.ops._ops.resize_pane import ResizePane +from libtmux.experimental.ops._ops.respawn_pane import RespawnPane from libtmux.experimental.ops._ops.select_layout import SelectLayout +from libtmux.experimental.ops._ops.select_pane import SelectPane from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.split_window import SplitWindow +from libtmux.experimental.ops._ops.swap_pane import SwapPane from libtmux.experimental.ops._ops.switch_client import SwitchClient __all__ = ( + "BreakPane", "CapturePane", + "ClearHistory", "DetachClient", "DisplayMessage", "HasSession", + "JoinPane", "KillPane", "KillSession", "KillWindow", + "LastPane", "ListClients", "ListPanes", "ListSessions", "ListWindows", + "MovePane", "NewSession", "NewWindow", + "PipePane", "RefreshClient", "RenameSession", "RenameWindow", + "ResizePane", + "RespawnPane", "SelectLayout", + "SelectPane", "SendKeys", "ShowOptions", "SplitWindow", + "SwapPane", "SwitchClient", ) diff --git a/src/libtmux/experimental/ops/_ops/break_pane.py b/src/libtmux/experimental/ops/_ops/break_pane.py new file mode 100644 index 000000000..1034def13 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/break_pane.py @@ -0,0 +1,89 @@ +"""The ``break-pane`` operation (creates a window, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class BreakPane(Operation[CreateResult]): + """Break a pane out into a new window (``break-pane``). + + The pane to break is the ``-s`` source (``src_target``); there is no ``-t``. + By default it appends ``-P -F '#{window_id}'`` so the new window's id is + captured into :attr:`~.results.CreateResult.new_id`. + + Parameters + ---------- + detach : bool + Do not switch to the new window (``-d``). + name : str or None + Name for the new window (``-n``). + capture : bool + Append ``-P -F '#{window_id}'`` to capture the new window id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> BreakPane(src_target=PaneId("%2"), name="logs").render() + ('break-pane', '-d', '-n', 'logs', '-P', '-F', '#{window_id}', '-s', '%2') + >>> BreakPane(src_target=PaneId("%2")).build_result( + ... returncode=0, stdout=("@7",) + ... ).new_id + '@7' + """ + + kind = "break_pane" + command = "break-pane" + scope = "window" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="window") + + detach: bool = True + name: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the break flags, capture template, and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.name is not None: + out.extend(("-n", self.name)) + if self.capture: + out.extend(("-P", "-F", "#{window_id}")) + out.extend(self.src_args()) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured new-window id.""" + new_id = stdout[0].strip() if status == "complete" and stdout else None + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=new_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/clear_history.py b/src/libtmux/experimental/ops/_ops/clear_history.py new file mode 100644 index 000000000..2be1b405d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/clear_history.py @@ -0,0 +1,30 @@ +"""The ``clear-history`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ClearHistory(Operation[AckResult]): + """Clear a pane's scrollback history (``clear-history``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> ClearHistory(target=PaneId("%1")).render() + ('clear-history', '-t', '%1') + """ + + kind = "clear_history" + command = "clear-history" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/join_pane.py b/src/libtmux/experimental/ops/_ops/join_pane.py new file mode 100644 index 000000000..7924517f5 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/join_pane.py @@ -0,0 +1,66 @@ +"""The ``join-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class JoinPane(Operation[AckResult]): + """Join a source pane into a destination window/pane (``join-pane``). + + ``target`` is the destination (``-t``); ``src_target`` is the pane to move + (``-s``). The inverse of :class:`BreakPane`. + + Parameters + ---------- + horizontal : bool + Split the destination left/right (``-h``) instead of top/bottom (``-v``). + detach : bool + Do not switch to the destination window (``-d``). + full_size : bool + Span the full window width/height (``-f``). + size : int or None + Size of the joined pane (``-l``). + before : bool + Place the pane before the destination (``-b``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> JoinPane(target=WindowId("@1"), src_target=PaneId("%2")).render() + ('join-pane', '-t', '@1', '-v', '-d', '-s', '%2') + """ + + kind = "join_pane" + command = "join-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + horizontal: bool = False + detach: bool = True + full_size: bool = False + size: int | None = None + before: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the join flags and ``-s`` source.""" + out: list[str] = ["-h" if self.horizontal else "-v"] + if self.detach: + out.append("-d") + if self.full_size: + out.append("-f") + if self.size is not None: + out.append(f"-l{self.size}") + if self.before: + out.append("-b") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/last_pane.py b/src/libtmux/experimental/ops/_ops/last_pane.py new file mode 100644 index 000000000..21fadb606 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/last_pane.py @@ -0,0 +1,32 @@ +"""The ``last-pane`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LastPane(Operation[AckResult]): + """Select the previously active pane in a window (``last-pane``). + + ``target`` is the window whose last pane to select. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> LastPane(target=WindowId("@1")).render() + ('last-pane', '-t', '@1') + """ + + kind = "last_pane" + command = "last-pane" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/move_pane.py b/src/libtmux/experimental/ops/_ops/move_pane.py new file mode 100644 index 000000000..75fe6feb4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/move_pane.py @@ -0,0 +1,27 @@ +"""The ``move-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._ops.join_pane import JoinPane +from libtmux.experimental.ops.registry import register + + +@register +@dataclass(frozen=True, kw_only=True) +class MovePane(JoinPane): + """Move a source pane into a destination window (``move-pane``). + + Identical in shape to :class:`JoinPane`; tmux exposes ``move-pane`` as the + same command under a different name. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> MovePane(target=WindowId("@1"), src_target=PaneId("%2")).render() + ('move-pane', '-t', '@1', '-v', '-d', '-s', '%2') + """ + + kind = "move_pane" + command = "move-pane" diff --git a/src/libtmux/experimental/ops/_ops/pipe_pane.py b/src/libtmux/experimental/ops/_ops/pipe_pane.py new file mode 100644 index 000000000..c30c45c0d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/pipe_pane.py @@ -0,0 +1,61 @@ +"""The ``pipe-pane`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PipePane(Operation[AckResult]): + """Pipe a pane's output to a shell command (``pipe-pane``). + + Parameters + ---------- + command : str or None + Shell command to pipe to. Omit to stop an existing pipe. + stdin : bool + Connect the pane's input to the command (``-I``). + stdout : bool + Connect the pane's output to the command (``-O``). + toggle : bool + Only toggle: stop if already piping the same command (``-o``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> PipePane(target=PaneId("%1"), command_line="cat >>/tmp/log").render() + ('pipe-pane', '-t', '%1', 'cat >>/tmp/log') + >>> PipePane(target=PaneId("%1")).render() + ('pipe-pane', '-t', '%1') + """ + + kind = "pipe_pane" + command = "pipe-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(reads_output=True) + + command_line: str | None = None + stdin: bool = False + stdout: bool = False + toggle: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the pipe flags and command.""" + out: list[str] = [] + if self.stdin: + out.append("-I") + if self.stdout: + out.append("-O") + if self.toggle: + out.append("-o") + if self.command_line is not None: + out.append(self.command_line) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/resize_pane.py b/src/libtmux/experimental/ops/_ops/resize_pane.py new file mode 100644 index 000000000..72b6624b8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/resize_pane.py @@ -0,0 +1,65 @@ +"""The ``resize-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ResizePane(Operation[AckResult]): + """Resize a pane, optionally zooming it. + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Resize toward a side (``-L``/``-R``/``-U``/``-D``). + adjustment : int or None + Cells to adjust by when *direction* is set. + width, height : int or None + Absolute width (``-x``) / height (``-y``) in cells. + zoom : bool + Toggle pane zoom (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> ResizePane(target=PaneId("%1"), height=20).render() + ('resize-pane', '-t', '%1', '-y20') + >>> ResizePane(target=PaneId("%1"), direction="D", adjustment=5).render() + ('resize-pane', '-t', '%1', '-D', '5') + """ + + kind = "resize_pane" + command = "resize-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + adjustment: int | None = None + width: int | None = None + height: int | None = None + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the resize flags.""" + out: list[str] = [] + if self.zoom: + out.append("-Z") + if self.direction is not None: + out.append(f"-{self.direction}") + if self.adjustment is not None: + out.append(str(self.adjustment)) + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/respawn_pane.py b/src/libtmux/experimental/ops/_ops/respawn_pane.py new file mode 100644 index 000000000..4b09f50c5 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/respawn_pane.py @@ -0,0 +1,64 @@ +"""The ``respawn-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + +@register +@dataclass(frozen=True, kw_only=True) +class RespawnPane(Operation[AckResult]): + """Restart the command in a (usually dead) pane (``respawn-pane``). + + Parameters + ---------- + kill : bool + Kill the existing process first (``-k``). + start_directory : str or None + Working directory for the new process (``-c``). + environment : Mapping[str, str] or None + Environment variables (``-e``; tmux 3.0+). + shell : str or None + Command to run instead of the default shell. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> RespawnPane(target=PaneId("%1"), kill=True).render() + ('respawn-pane', '-t', '%1', '-k') + """ + + kind = "respawn_pane" + command = "respawn-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + kill: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the respawn flags.""" + out: list[str] = [] + if self.kill: + out.append("-k") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.shell is not None: + out.append(self.shell) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/select_pane.py b/src/libtmux/experimental/ops/_ops/select_pane.py new file mode 100644 index 000000000..0d11562eb --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_pane.py @@ -0,0 +1,70 @@ +"""The ``select-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectPane(Operation[AckResult]): + """Make a pane active, or move/mark the selection. + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Move to the pane left/right/above/below the target. + last : bool + Select the last (previously active) pane (``-l``). + mark, unmark : bool + Set (``-m``) or clear (``-M``) the marked pane. + zoom : bool + Keep the window zoomed (``-Z``). + title : str or None + Set the pane title (``-T``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SelectPane(target=PaneId("%1")).render() + ('select-pane', '-t', '%1') + >>> SelectPane(target=PaneId("%2"), direction="L", zoom=True).render() + ('select-pane', '-t', '%2', '-L', '-Z') + """ + + kind = "select_pane" + command = "select-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + last: bool = False + mark: bool = False + unmark: bool = False + zoom: bool = False + title: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the selection flags.""" + out: list[str] = [] + if self.last: + out.append("-l") + if self.direction is not None: + out.append(f"-{self.direction}") + if self.mark: + out.append("-m") + if self.unmark: + out.append("-M") + if self.zoom: + out.append("-Z") + if self.title is not None: + out.extend(("-T", self.title)) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/swap_pane.py b/src/libtmux/experimental/ops/_ops/swap_pane.py new file mode 100644 index 000000000..bb8cce9b0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/swap_pane.py @@ -0,0 +1,63 @@ +"""The ``swap-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwapPane(Operation[AckResult]): + """Swap two panes (``swap-pane``). + + ``target`` is the destination pane (``-t``); ``src_target`` is the source + pane (``-s``). With *up*/*down* and no source, swap with the adjacent pane. + + Parameters + ---------- + detach : bool + Do not change the active pane (``-d``). + up, down : bool + Swap with the pane above (``-U``) / below (``-D``). + zoom : bool + Keep the window zoomed (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SwapPane(target=PaneId("%1"), src_target=PaneId("%2")).render() + ('swap-pane', '-t', '%1', '-s', '%2') + >>> SwapPane(target=PaneId("%1"), down=True, detach=True).render() + ('swap-pane', '-t', '%1', '-d', '-D') + """ + + kind = "swap_pane" + command = "swap-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + up: bool = False + down: bool = False + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the swap flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.up: + out.append("-U") + if self.down: + out.append("-D") + if self.zoom: + out.append("-Z") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 35484094a..1841a9069 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,11 +65,13 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'detach_client', 'display_message', 'has_session', - 'kill_pane', 'kill_session', 'kill_window', 'list_clients', 'list_panes', - 'list_sessions', 'list_windows', 'new_session', 'new_window', - 'refresh_client', 'rename_session', 'rename_window', 'select_layout', - 'send_keys', 'show_options', 'split_window', 'switch_client'] + ['break_pane', 'capture_pane', 'clear_history', 'detach_client', + 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_session', + 'kill_window', 'last_pane', 'list_clients', 'list_panes', 'list_sessions', + 'list_windows', 'move_pane', 'new_session', 'new_window', 'pipe_pane', + 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', + 'respawn_pane', 'select_layout', 'select_pane', 'send_keys', 'show_options', + 'split_window', 'swap_pane', 'switch_client'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index 57479717f..84c2bdd30 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -51,6 +51,9 @@ class Operation(t.Generic[ResultT]): ---------- target : Target or None The ``-t`` target, or ``None`` for "no explicit target". + src_target : Target or None + The ``-s`` source target for dual-target commands (``swap-pane``, + ``join-pane``, ``link-window``, ...), or ``None`` when unused. Notes ----- @@ -81,6 +84,7 @@ class Operation(t.Generic[ResultT]): """ target: Target | None = None + src_target: Target | None = None kind: t.ClassVar[str] command: t.ClassVar[str] @@ -195,6 +199,21 @@ def flag_available(self, label: str, version: str | None) -> bool: return True return LooseVersion(version) >= LooseVersion(need) + def src_args(self) -> tuple[str, ...]: + """Render the ``-s`` source target, or ``()`` when there is none. + + Dual-target commands call this from :meth:`args` to emit their source. + + Examples + -------- + >>> from libtmux.experimental.ops import SwapPane + >>> from libtmux.experimental.ops._types import PaneId + >>> SwapPane(target=PaneId("%1"), src_target=PaneId("%2")).src_args() + ('-s', '%2') + """ + token = render_target(self.src_target) + return ("-s", token) if token is not None else () + def build_result( self, *, diff --git a/src/libtmux/experimental/ops/serialize.py b/src/libtmux/experimental/ops/serialize.py index d65dff506..e96de5ff2 100644 --- a/src/libtmux/experimental/ops/serialize.py +++ b/src/libtmux/experimental/ops/serialize.py @@ -103,8 +103,8 @@ def operation_to_dict(operation: Operation[t.Any]) -> dict[str, t.Any]: data: dict[str, t.Any] = {"kind": operation.kind} for field in dataclasses.fields(operation): value = getattr(operation, field.name) - if field.name == "target": - data["target"] = target_to_dict(value) + if field.name in {"target", "src_target"}: + data[field.name] = target_to_dict(value) else: data[field.name] = _jsonify(value) return data @@ -126,8 +126,8 @@ def operation_from_dict(data: Mapping[str, t.Any]) -> Operation[t.Any]: for field in dataclasses.fields(operation_cls): if field.name not in data: continue - if field.name == "target": - kwargs["target"] = target_from_dict(data["target"]) + if field.name in {"target", "src_target"}: + kwargs[field.name] = target_from_dict(data[field.name]) else: kwargs[field.name] = data[field.name] return operation_cls(**kwargs) diff --git a/tests/experimental/ops/test_pane_ops.py b/tests/experimental/ops/test_pane_ops.py new file mode 100644 index 000000000..eecad6c72 --- /dev/null +++ b/tests/experimental/ops/test_pane_ops.py @@ -0,0 +1,207 @@ +"""Tests for the pane mutation/creation operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + BreakPane, + ClearHistory, + JoinPane, + LastPane, + MovePane, + PipePane, + ResizePane, + RespawnPane, + SelectPane, + SplitWindow, + SwapPane, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="select_pane", + op=SelectPane(target=PaneId("%1")), + expected=("select-pane", "-t", "%1"), + ), + RenderCase( + test_id="select_pane_direction_zoom", + op=SelectPane(target=PaneId("%2"), direction="L", zoom=True), + expected=("select-pane", "-t", "%2", "-L", "-Z"), + ), + RenderCase( + test_id="last_pane", + op=LastPane(target=WindowId("@1")), + expected=("last-pane", "-t", "@1"), + ), + RenderCase( + test_id="resize_pane_height", + op=ResizePane(target=PaneId("%1"), height=20), + expected=("resize-pane", "-t", "%1", "-y20"), + ), + RenderCase( + test_id="resize_pane_direction", + op=ResizePane(target=PaneId("%1"), direction="D", adjustment=5), + expected=("resize-pane", "-t", "%1", "-D", "5"), + ), + RenderCase( + test_id="respawn_pane_kill", + op=RespawnPane(target=PaneId("%1"), kill=True), + expected=("respawn-pane", "-t", "%1", "-k"), + ), + RenderCase( + test_id="pipe_pane", + op=PipePane(target=PaneId("%1"), command_line="cat"), + expected=("pipe-pane", "-t", "%1", "cat"), + ), + RenderCase( + test_id="clear_history", + op=ClearHistory(target=PaneId("%1")), + expected=("clear-history", "-t", "%1"), + ), + RenderCase( + test_id="swap_pane", + op=SwapPane(target=PaneId("%1"), src_target=PaneId("%2")), + expected=("swap-pane", "-t", "%1", "-s", "%2"), + ), + RenderCase( + test_id="join_pane", + op=JoinPane(target=WindowId("@1"), src_target=PaneId("%2")), + expected=("join-pane", "-t", "@1", "-v", "-d", "-s", "%2"), + ), + RenderCase( + test_id="move_pane", + op=MovePane(target=WindowId("@1"), src_target=PaneId("%2")), + expected=("move-pane", "-t", "@1", "-v", "-d", "-s", "%2"), + ), + RenderCase( + test_id="break_pane", + op=BreakPane(src_target=PaneId("%2"), name="logs"), + expected=( + "break-pane", + "-d", + "-n", + "logs", + "-P", + "-F", + "#{window_id}", + "-s", + "%2", + ), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_pane_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each pane op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_pane_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op (incl. its src_target) and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0, stdout=("@7",)) + assert result_from_dict(result_to_dict(result)) == result + + +def test_break_pane_captures_new_window_id() -> None: + """break-pane parses the captured window id into the typed result.""" + result = BreakPane(src_target=PaneId("%2")).build_result( + returncode=0, + stdout=("@9",), + ) + assert result.new_id == "@9" + assert result.created_id == "@9" + + +def test_select_pane_live(session: Session) -> None: + """select-pane makes the requested pane active.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + original = session.active_pane + assert original is not None and original.pane_id is not None + + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + run(SelectPane(target=PaneId(original.pane_id)), engine).raise_for_status() + + window.refresh() + active = window.active_pane + assert active is not None + assert active.pane_id == original.pane_id + + +def test_resize_and_clear_live(session: Session) -> None: + """resize-pane and clear-history succeed against a real pane.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + assert run(ResizePane(target=PaneId(pane.pane_id), height=10), engine).ok + assert run(ClearHistory(target=PaneId(pane.pane_id)), engine).ok + + +def test_break_and_swap_live(session: Session) -> None: + """break-pane creates a window; swap-pane swaps two real panes.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + + split = run(SplitWindow(target=WindowId(window.window_id)), engine) + new_pane = split.new_pane_id + assert new_pane is not None + + window.refresh() + first = window.panes[0].pane_id + assert first is not None + assert run(SwapPane(target=PaneId(first), src_target=PaneId(new_pane)), engine).ok + + broken = run(BreakPane(src_target=PaneId(new_pane)), engine) + assert broken.ok + assert broken.new_id is not None + assert session.server.windows.get(window_id=broken.new_id) is not None From 30cd6a3259b1fba389df084704a133bacf290663 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:54:13 -0500 Subject: [PATCH 020/223] Ops(feat): Add window mutation/navigation operations why: Window-level parity was missing the verbs the ORM uses to navigate and rearrange windows, so engine-driven callers could not select, move, or relink windows. what: - Add select-window, last-window, next-window, previous-window, resize-window, rotate-window, respawn-window, unlink-window - Add swap-window, move-window, link-window (dual-target, via -s src_target) - Wire ops/exports; extend the catalog kind-enumeration doctest - Add test_window_ops.py (NamedTuple + test_id render/round-trip cases plus live navigation/swap/move/unlink coverage) --- src/libtmux/experimental/ops/__init__.py | 22 ++ src/libtmux/experimental/ops/_ops/__init__.py | 22 ++ .../experimental/ops/_ops/last_window.py | 32 +++ .../experimental/ops/_ops/link_window.py | 61 +++++ .../experimental/ops/_ops/move_window.py | 61 +++++ .../experimental/ops/_ops/next_window.py | 41 ++++ .../experimental/ops/_ops/previous_window.py | 41 ++++ .../experimental/ops/_ops/resize_window.py | 58 +++++ .../experimental/ops/_ops/respawn_window.py | 64 ++++++ .../experimental/ops/_ops/rotate_window.py | 54 +++++ .../experimental/ops/_ops/select_window.py | 30 +++ .../experimental/ops/_ops/swap_window.py | 48 ++++ .../experimental/ops/_ops/unlink_window.py | 41 ++++ src/libtmux/experimental/ops/catalog.py | 10 +- tests/experimental/ops/test_window_ops.py | 216 ++++++++++++++++++ 15 files changed, 797 insertions(+), 4 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/last_window.py create mode 100644 src/libtmux/experimental/ops/_ops/link_window.py create mode 100644 src/libtmux/experimental/ops/_ops/move_window.py create mode 100644 src/libtmux/experimental/ops/_ops/next_window.py create mode 100644 src/libtmux/experimental/ops/_ops/previous_window.py create mode 100644 src/libtmux/experimental/ops/_ops/resize_window.py create mode 100644 src/libtmux/experimental/ops/_ops/respawn_window.py create mode 100644 src/libtmux/experimental/ops/_ops/rotate_window.py create mode 100644 src/libtmux/experimental/ops/_ops/select_window.py create mode 100644 src/libtmux/experimental/ops/_ops/swap_window.py create mode 100644 src/libtmux/experimental/ops/_ops/unlink_window.py create mode 100644 tests/experimental/ops/test_window_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index fa229bef8..402122afb 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -32,26 +32,37 @@ KillSession, KillWindow, LastPane, + LastWindow, + LinkWindow, ListClients, ListPanes, ListSessions, ListWindows, MovePane, + MoveWindow, NewSession, NewWindow, + NextWindow, PipePane, + PreviousWindow, RefreshClient, RenameSession, RenameWindow, ResizePane, + ResizeWindow, RespawnPane, + RespawnWindow, + RotateWindow, SelectLayout, SelectPane, + SelectWindow, SendKeys, ShowOptions, SplitWindow, SwapPane, + SwapWindow, SwitchClient, + UnlinkWindow, ) from libtmux.experimental.ops._types import ( ClientName, @@ -140,7 +151,9 @@ "KillSession", "KillWindow", "LastPane", + "LastWindow", "LazyPlan", + "LinkWindow", "ListClients", "ListClientsResult", "ListPanes", @@ -151,9 +164,11 @@ "ListWindowsResult", "MarkedPlanner", "MovePane", + "MoveWindow", "NameRef", "NewSession", "NewWindow", + "NextWindow", "OpChain", "OpSpec", "Operation", @@ -164,16 +179,21 @@ "PlanResult", "PlanStep", "Planner", + "PreviousWindow", "RefreshClient", "RenameSession", "RenameWindow", "ResizePane", + "ResizeWindow", "RespawnPane", + "RespawnWindow", "Result", + "RotateWindow", "Safety", "Scope", "SelectLayout", "SelectPane", + "SelectWindow", "SendKeys", "SequentialPlanner", "SessionId", @@ -185,10 +205,12 @@ "SplitWindowResult", "Status", "SwapPane", + "SwapWindow", "SwitchClient", "Target", "TmuxCommandError", "UnknownOperation", + "UnlinkWindow", "VersionUnsupported", "WindowId", "arun", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 4b54007fd..0b6593907 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -18,26 +18,37 @@ from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow from libtmux.experimental.ops._ops.last_pane import LastPane +from libtmux.experimental.ops._ops.last_window import LastWindow +from libtmux.experimental.ops._ops.link_window import LinkWindow from libtmux.experimental.ops._ops.list_clients import ListClients from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows from libtmux.experimental.ops._ops.move_pane import MovePane +from libtmux.experimental.ops._ops.move_window import MoveWindow from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.next_window import NextWindow from libtmux.experimental.ops._ops.pipe_pane import PipePane +from libtmux.experimental.ops._ops.previous_window import PreviousWindow from libtmux.experimental.ops._ops.refresh_client import RefreshClient from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.resize_pane import ResizePane +from libtmux.experimental.ops._ops.resize_window import ResizeWindow from libtmux.experimental.ops._ops.respawn_pane import RespawnPane +from libtmux.experimental.ops._ops.respawn_window import RespawnWindow +from libtmux.experimental.ops._ops.rotate_window import RotateWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.select_pane import SelectPane +from libtmux.experimental.ops._ops.select_window import SelectWindow from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.split_window import SplitWindow from libtmux.experimental.ops._ops.swap_pane import SwapPane +from libtmux.experimental.ops._ops.swap_window import SwapWindow from libtmux.experimental.ops._ops.switch_client import SwitchClient +from libtmux.experimental.ops._ops.unlink_window import UnlinkWindow __all__ = ( "BreakPane", @@ -51,24 +62,35 @@ "KillSession", "KillWindow", "LastPane", + "LastWindow", + "LinkWindow", "ListClients", "ListPanes", "ListSessions", "ListWindows", "MovePane", + "MoveWindow", "NewSession", "NewWindow", + "NextWindow", "PipePane", + "PreviousWindow", "RefreshClient", "RenameSession", "RenameWindow", "ResizePane", + "ResizeWindow", "RespawnPane", + "RespawnWindow", + "RotateWindow", "SelectLayout", "SelectPane", + "SelectWindow", "SendKeys", "ShowOptions", "SplitWindow", "SwapPane", + "SwapWindow", "SwitchClient", + "UnlinkWindow", ) diff --git a/src/libtmux/experimental/ops/_ops/last_window.py b/src/libtmux/experimental/ops/_ops/last_window.py new file mode 100644 index 000000000..b62fa2732 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/last_window.py @@ -0,0 +1,32 @@ +"""The ``last-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LastWindow(Operation[AckResult]): + """Select the previously active window (``last-window``). + + ``target`` is the session. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> LastWindow(target=SessionId("$0")).render() + ('last-window', '-t', '$0') + """ + + kind = "last_window" + command = "last-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/link_window.py b/src/libtmux/experimental/ops/_ops/link_window.py new file mode 100644 index 000000000..2eb640498 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/link_window.py @@ -0,0 +1,61 @@ +"""The ``link-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LinkWindow(Operation[AckResult]): + """Link a window into another session (``link-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the window to + link (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + before, after : bool + Insert before (``-b``) or after (``-a``) the destination index. + kill : bool + Replace (kill) any window already at the destination (``-k``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId, WindowId + >>> LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")).render() + ('link-window', '-t', '$0', '-s', '@2') + """ + + kind = "link_window" + command = "link-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + before: bool = False + after: bool = False + kill: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the link flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.before: + out.append("-b") + if self.after: + out.append("-a") + if self.kill: + out.append("-k") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/move_window.py b/src/libtmux/experimental/ops/_ops/move_window.py new file mode 100644 index 000000000..525f2944d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/move_window.py @@ -0,0 +1,61 @@ +"""The ``move-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class MoveWindow(Operation[AckResult]): + """Move a window to a new index/session (``move-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the window to + move (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + before, after : bool + Insert before (``-b``) or after (``-a``) the destination index. + renumber : bool + Renumber windows to close gaps (``-r``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId, WindowId + >>> MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")).render() + ('move-window', '-t', '$0', '-s', '@2') + """ + + kind = "move_window" + command = "move-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + before: bool = False + after: bool = False + renumber: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the move flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.before: + out.append("-b") + if self.after: + out.append("-a") + if self.renumber: + out.append("-r") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/next_window.py b/src/libtmux/experimental/ops/_ops/next_window.py new file mode 100644 index 000000000..0087aba16 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/next_window.py @@ -0,0 +1,41 @@ +"""The ``next-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class NextWindow(Operation[AckResult]): + """Select the next window in a session (``next-window``). + + Parameters + ---------- + alert : bool + Move to the next window with an alert (``-a``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> NextWindow(target=SessionId("$0")).render() + ('next-window', '-t', '$0') + """ + + kind = "next_window" + command = "next-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + alert: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.alert else () diff --git a/src/libtmux/experimental/ops/_ops/previous_window.py b/src/libtmux/experimental/ops/_ops/previous_window.py new file mode 100644 index 000000000..25cf1e2d0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/previous_window.py @@ -0,0 +1,41 @@ +"""The ``previous-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PreviousWindow(Operation[AckResult]): + """Select the previous window in a session (``previous-window``). + + Parameters + ---------- + alert : bool + Move to the previous window with an alert (``-a``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> PreviousWindow(target=SessionId("$0")).render() + ('previous-window', '-t', '$0') + """ + + kind = "previous_window" + command = "previous-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + alert: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.alert else () diff --git a/src/libtmux/experimental/ops/_ops/resize_window.py b/src/libtmux/experimental/ops/_ops/resize_window.py new file mode 100644 index 000000000..952753340 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/resize_window.py @@ -0,0 +1,58 @@ +"""The ``resize-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ResizeWindow(Operation[AckResult]): + """Resize a window (``resize-window``). + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Resize toward a side (``-L``/``-R``/``-U``/``-D``). + adjustment : int or None + Cells to adjust by when *direction* is set. + width, height : int or None + Absolute width (``-x``) / height (``-y``) in cells. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> ResizeWindow(target=WindowId("@1"), width=100).render() + ('resize-window', '-t', '@1', '-x100') + """ + + kind = "resize_window" + command = "resize-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + adjustment: int | None = None + width: int | None = None + height: int | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the resize flags.""" + out: list[str] = [] + if self.direction is not None: + out.append(f"-{self.direction}") + if self.adjustment is not None: + out.append(str(self.adjustment)) + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/respawn_window.py b/src/libtmux/experimental/ops/_ops/respawn_window.py new file mode 100644 index 000000000..93926cf68 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/respawn_window.py @@ -0,0 +1,64 @@ +"""The ``respawn-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + +@register +@dataclass(frozen=True, kw_only=True) +class RespawnWindow(Operation[AckResult]): + """Restart the command in a (usually dead) window (``respawn-window``). + + Parameters + ---------- + kill : bool + Kill the existing process first (``-k``). + start_directory : str or None + Working directory for the new process (``-c``). + environment : Mapping[str, str] or None + Environment variables (``-e``; tmux 3.0+). + shell : str or None + Command to run instead of the default shell. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RespawnWindow(target=WindowId("@1"), kill=True).render() + ('respawn-window', '-t', '@1', '-k') + """ + + kind = "respawn_window" + command = "respawn-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + kill: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the respawn flags.""" + out: list[str] = [] + if self.kill: + out.append("-k") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.shell is not None: + out.append(self.shell) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/rotate_window.py b/src/libtmux/experimental/ops/_ops/rotate_window.py new file mode 100644 index 000000000..1ceff7b4c --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rotate_window.py @@ -0,0 +1,54 @@ +"""The ``rotate-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RotateWindow(Operation[AckResult]): + """Rotate the panes in a window (``rotate-window``). + + Parameters + ---------- + up, down : bool + Rotate upward (``-U``) or downward (``-D``). + zoom : bool + Keep the window zoomed (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RotateWindow(target=WindowId("@1")).render() + ('rotate-window', '-t', '@1') + >>> RotateWindow(target=WindowId("@1"), up=True).render() + ('rotate-window', '-t', '@1', '-U') + """ + + kind = "rotate_window" + command = "rotate-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + up: bool = False + down: bool = False + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the rotate flags.""" + out: list[str] = [] + if self.up: + out.append("-U") + if self.down: + out.append("-D") + if self.zoom: + out.append("-Z") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/select_window.py b/src/libtmux/experimental/ops/_ops/select_window.py new file mode 100644 index 000000000..0ce5412d8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_window.py @@ -0,0 +1,30 @@ +"""The ``select-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectWindow(Operation[AckResult]): + """Make a window active (``select-window``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SelectWindow(target=WindowId("@1")).render() + ('select-window', '-t', '@1') + """ + + kind = "select_window" + command = "select-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/swap_window.py b/src/libtmux/experimental/ops/_ops/swap_window.py new file mode 100644 index 000000000..b3237a726 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/swap_window.py @@ -0,0 +1,48 @@ +"""The ``swap-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwapWindow(Operation[AckResult]): + """Swap two windows (``swap-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the source + window (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SwapWindow(target=WindowId("@1"), src_target=WindowId("@2")).render() + ('swap-window', '-t', '@1', '-s', '@2') + """ + + kind = "swap_window" + command = "swap-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-d`` flag and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/unlink_window.py b/src/libtmux/experimental/ops/_ops/unlink_window.py new file mode 100644 index 000000000..08c7805de --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/unlink_window.py @@ -0,0 +1,41 @@ +"""The ``unlink-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class UnlinkWindow(Operation[AckResult]): + """Unlink a window from a session (``unlink-window``). + + Parameters + ---------- + kill : bool + Also destroy the window if it is no longer linked anywhere (``-k``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> UnlinkWindow(target=WindowId("@1")).render() + ('unlink-window', '-t', '@1') + """ + + kind = "unlink_window" + command = "unlink-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + kill: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-k`` flag.""" + return ("-k",) if self.kill else () diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 1841a9069..7eae45b61 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -67,11 +67,13 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> [entry.kind for entry in entries] ['break_pane', 'capture_pane', 'clear_history', 'detach_client', 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_session', - 'kill_window', 'last_pane', 'list_clients', 'list_panes', 'list_sessions', - 'list_windows', 'move_pane', 'new_session', 'new_window', 'pipe_pane', + 'kill_window', 'last_pane', 'last_window', 'link_window', 'list_clients', + 'list_panes', 'list_sessions', 'list_windows', 'move_pane', 'move_window', + 'new_session', 'new_window', 'next_window', 'pipe_pane', 'previous_window', 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', - 'respawn_pane', 'select_layout', 'select_pane', 'send_keys', 'show_options', - 'split_window', 'swap_pane', 'switch_client'] + 'resize_window', 'respawn_pane', 'respawn_window', 'rotate_window', + 'select_layout', 'select_pane', 'select_window', 'send_keys', 'show_options', + 'split_window', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/tests/experimental/ops/test_window_ops.py b/tests/experimental/ops/test_window_ops.py new file mode 100644 index 000000000..b428998a5 --- /dev/null +++ b/tests/experimental/ops/test_window_ops.py @@ -0,0 +1,216 @@ +"""Tests for the window mutation/navigation operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + LastWindow, + LinkWindow, + MoveWindow, + NewWindow, + NextWindow, + PreviousWindow, + ResizeWindow, + RespawnWindow, + RotateWindow, + SelectWindow, + SplitWindow, + SwapWindow, + UnlinkWindow, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import IndexRef, SessionId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="select_window", + op=SelectWindow(target=WindowId("@1")), + expected=("select-window", "-t", "@1"), + ), + RenderCase( + test_id="last_window", + op=LastWindow(target=SessionId("$0")), + expected=("last-window", "-t", "$0"), + ), + RenderCase( + test_id="next_window", + op=NextWindow(target=SessionId("$0")), + expected=("next-window", "-t", "$0"), + ), + RenderCase( + test_id="next_window_alert", + op=NextWindow(target=SessionId("$0"), alert=True), + expected=("next-window", "-t", "$0", "-a"), + ), + RenderCase( + test_id="previous_window", + op=PreviousWindow(target=SessionId("$0")), + expected=("previous-window", "-t", "$0"), + ), + RenderCase( + test_id="resize_window_width", + op=ResizeWindow(target=WindowId("@1"), width=100), + expected=("resize-window", "-t", "@1", "-x100"), + ), + RenderCase( + test_id="rotate_window_up", + op=RotateWindow(target=WindowId("@1"), up=True), + expected=("rotate-window", "-t", "@1", "-U"), + ), + RenderCase( + test_id="respawn_window_kill", + op=RespawnWindow(target=WindowId("@1"), kill=True), + expected=("respawn-window", "-t", "@1", "-k"), + ), + RenderCase( + test_id="unlink_window_kill", + op=UnlinkWindow(target=WindowId("@1"), kill=True), + expected=("unlink-window", "-t", "@1", "-k"), + ), + RenderCase( + test_id="swap_window", + op=SwapWindow(target=WindowId("@1"), src_target=WindowId("@2")), + expected=("swap-window", "-t", "@1", "-s", "@2"), + ), + RenderCase( + test_id="move_window", + op=MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")), + expected=("move-window", "-t", "$0", "-s", "@2"), + ), + RenderCase( + test_id="link_window", + op=LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")), + expected=("link-window", "-t", "$0", "-s", "@2"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_window_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each window op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_window_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op (incl. its src_target) and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_window_navigation_live(session: Session) -> None: + """select/next/previous/last-window move the active window.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + + session.refresh() + first = session.windows[0].window_id + assert first is not None + run(SelectWindow(target=WindowId(first)), engine).raise_for_status() + session.refresh() + assert session.active_window.window_id == first + + run(NextWindow(target=SessionId(sid)), engine).raise_for_status() + session.refresh() + assert session.active_window.window_id != first + + assert run(LastWindow(target=SessionId(sid)), engine).ok + assert run(PreviousWindow(target=SessionId(sid)), engine).ok + + +def test_resize_and_rotate_live(session: Session) -> None: + """resize-window and rotate-window succeed against a real window.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + assert run(ResizeWindow(target=WindowId(window.window_id), width=90), engine).ok + assert run(RotateWindow(target=WindowId(window.window_id)), engine).ok + + +def test_swap_and_move_live(session: Session) -> None: + """swap-window swaps two windows; move-window relocates one by index.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + session.refresh() + first = session.windows[0].window_id + second = session.windows[1].window_id + assert first is not None and second is not None + + assert run( + SwapWindow(target=WindowId(first), src_target=WindowId(second)), + engine, + ).ok + assert run( + MoveWindow(target=IndexRef(9, parent=sid), src_target=WindowId(first)), + engine, + ).ok + + +def test_unlink_window_live(session: Session) -> None: + """unlink-window -k removes a window from its session.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + created = run(NewWindow(target=SessionId(sid)), engine) + created.raise_for_status() + new_id = created.new_id + assert new_id is not None + + assert run(UnlinkWindow(target=WindowId(new_id), kill=True), engine).ok + session.refresh() + assert session.windows.get(window_id=new_id, default=None) is None From fb2c3b2c50a45a007605da42eb73122033b3fa60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:00:31 -0500 Subject: [PATCH 021/223] Ops(feat): Add server/option/environment operations why: Engine-driven callers had no typed way to drive the tmux server lifecycle or write options, environment, and hooks -- the write side of the options surface that show-options already read. what: - Add start-server, kill-server, run-shell, source-file, suspend-client lifecycle ops - Add set-option, set-window-option (the write counterpart to show-options), set-environment, set-hook - Wire ops/exports; extend the catalog kind-enumeration doctest - Add test_lifecycle_ops.py (NamedTuple + test_id render/round-trip cases plus live option/env/hook/run-shell/source-file coverage) --- src/libtmux/experimental/ops/__init__.py | 18 ++ src/libtmux/experimental/ops/_ops/__init__.py | 18 ++ .../experimental/ops/_ops/kill_server.py | 29 +++ .../experimental/ops/_ops/run_shell.py | 53 +++++ .../experimental/ops/_ops/set_environment.py | 64 ++++++ src/libtmux/experimental/ops/_ops/set_hook.py | 57 +++++ .../experimental/ops/_ops/set_option.py | 80 +++++++ .../ops/_ops/set_window_option.py | 62 ++++++ .../experimental/ops/_ops/source_file.py | 57 +++++ .../experimental/ops/_ops/start_server.py | 29 +++ .../experimental/ops/_ops/suspend_client.py | 32 +++ src/libtmux/experimental/ops/catalog.py | 18 +- tests/experimental/ops/test_lifecycle_ops.py | 200 ++++++++++++++++++ 13 files changed, 709 insertions(+), 8 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/kill_server.py create mode 100644 src/libtmux/experimental/ops/_ops/run_shell.py create mode 100644 src/libtmux/experimental/ops/_ops/set_environment.py create mode 100644 src/libtmux/experimental/ops/_ops/set_hook.py create mode 100644 src/libtmux/experimental/ops/_ops/set_option.py create mode 100644 src/libtmux/experimental/ops/_ops/set_window_option.py create mode 100644 src/libtmux/experimental/ops/_ops/source_file.py create mode 100644 src/libtmux/experimental/ops/_ops/start_server.py create mode 100644 src/libtmux/experimental/ops/_ops/suspend_client.py create mode 100644 tests/experimental/ops/test_lifecycle_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 402122afb..d21b099e5 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -29,6 +29,7 @@ HasSession, JoinPane, KillPane, + KillServer, KillSession, KillWindow, LastPane, @@ -53,12 +54,20 @@ RespawnPane, RespawnWindow, RotateWindow, + RunShell, SelectLayout, SelectPane, SelectWindow, SendKeys, + SetEnvironment, + SetHook, + SetOption, + SetWindowOption, ShowOptions, + SourceFile, SplitWindow, + StartServer, + SuspendClient, SwapPane, SwapWindow, SwitchClient, @@ -148,6 +157,7 @@ "IndexRef", "JoinPane", "KillPane", + "KillServer", "KillSession", "KillWindow", "LastPane", @@ -189,6 +199,7 @@ "RespawnWindow", "Result", "RotateWindow", + "RunShell", "Safety", "Scope", "SelectLayout", @@ -197,13 +208,20 @@ "SendKeys", "SequentialPlanner", "SessionId", + "SetEnvironment", + "SetHook", + "SetOption", + "SetWindowOption", "ShowOptions", "ShowOptionsResult", "SlotRef", + "SourceFile", "Special", "SplitWindow", "SplitWindowResult", + "StartServer", "Status", + "SuspendClient", "SwapPane", "SwapWindow", "SwitchClient", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 0b6593907..66e285049 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -15,6 +15,7 @@ from libtmux.experimental.ops._ops.has_session import HasSession from libtmux.experimental.ops._ops.join_pane import JoinPane from libtmux.experimental.ops._ops.kill_pane import KillPane +from libtmux.experimental.ops._ops.kill_server import KillServer from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow from libtmux.experimental.ops._ops.last_pane import LastPane @@ -39,12 +40,20 @@ from libtmux.experimental.ops._ops.respawn_pane import RespawnPane from libtmux.experimental.ops._ops.respawn_window import RespawnWindow from libtmux.experimental.ops._ops.rotate_window import RotateWindow +from libtmux.experimental.ops._ops.run_shell import RunShell from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.select_pane import SelectPane from libtmux.experimental.ops._ops.select_window import SelectWindow from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.set_environment import SetEnvironment +from libtmux.experimental.ops._ops.set_hook import SetHook +from libtmux.experimental.ops._ops.set_option import SetOption +from libtmux.experimental.ops._ops.set_window_option import SetWindowOption from libtmux.experimental.ops._ops.show_options import ShowOptions +from libtmux.experimental.ops._ops.source_file import SourceFile from libtmux.experimental.ops._ops.split_window import SplitWindow +from libtmux.experimental.ops._ops.start_server import StartServer +from libtmux.experimental.ops._ops.suspend_client import SuspendClient from libtmux.experimental.ops._ops.swap_pane import SwapPane from libtmux.experimental.ops._ops.swap_window import SwapWindow from libtmux.experimental.ops._ops.switch_client import SwitchClient @@ -59,6 +68,7 @@ "HasSession", "JoinPane", "KillPane", + "KillServer", "KillSession", "KillWindow", "LastPane", @@ -83,12 +93,20 @@ "RespawnPane", "RespawnWindow", "RotateWindow", + "RunShell", "SelectLayout", "SelectPane", "SelectWindow", "SendKeys", + "SetEnvironment", + "SetHook", + "SetOption", + "SetWindowOption", "ShowOptions", + "SourceFile", "SplitWindow", + "StartServer", + "SuspendClient", "SwapPane", "SwapWindow", "SwitchClient", diff --git a/src/libtmux/experimental/ops/_ops/kill_server.py b/src/libtmux/experimental/ops/_ops/kill_server.py new file mode 100644 index 000000000..a5754d648 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_server.py @@ -0,0 +1,29 @@ +"""The ``kill-server`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillServer(Operation[AckResult]): + """Kill the tmux server and all its sessions (``kill-server``). + + Examples + -------- + >>> KillServer().render() + ('kill-server',) + """ + + kind = "kill_server" + command = "kill-server" + scope = "server" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) diff --git a/src/libtmux/experimental/ops/_ops/run_shell.py b/src/libtmux/experimental/ops/_ops/run_shell.py new file mode 100644 index 000000000..9fab49ece --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/run_shell.py @@ -0,0 +1,53 @@ +"""The ``run-shell`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RunShell(Operation[AckResult]): + """Run a shell command via tmux (``run-shell``). + + Parameters + ---------- + command_line : str or None + The shell command to run. + background : bool + Run in the background (``-b``). + delay : int or None + Delay in seconds before running (``-d``). + + Examples + -------- + >>> RunShell(command_line="echo hi").render() + ('run-shell', 'echo hi') + """ + + kind = "run_shell" + command = "run-shell" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + command_line: str | None = None + background: bool = False + delay: int | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the run-shell flags and command.""" + out: list[str] = [] + if self.background: + out.append("-b") + if self.delay is not None: + out.extend(("-d", str(self.delay))) + if self.command_line is not None: + out.append(self.command_line) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_environment.py b/src/libtmux/experimental/ops/_ops/set_environment.py new file mode 100644 index 000000000..dac3bce0a --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_environment.py @@ -0,0 +1,64 @@ +"""The ``set-environment`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetEnvironment(Operation[AckResult]): + """Set or unset a session environment variable (``set-environment``). + + Parameters + ---------- + name : str + The variable name. + value : str or None + The value to set (omit when *remove*/*unset*). + global_ : bool + Apply to the global environment (``-g``). + remove : bool + Remove the variable from the environment (``-r``). + unset : bool + Unset the variable (``-u``). + + Examples + -------- + >>> SetEnvironment(name="FOO", value="bar").render() + ('set-environment', 'FOO', 'bar') + >>> SetEnvironment(global_=True, name="FOO", unset=True).render() + ('set-environment', '-g', '-u', 'FOO') + """ + + kind = "set_environment" + command = "set-environment" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + name: str + value: str | None = None + global_: bool = False + remove: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, name, and value.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.remove: + out.append("-r") + if self.unset: + out.append("-u") + out.append(self.name) + if self.value is not None and not (self.unset or self.remove): + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_hook.py b/src/libtmux/experimental/ops/_ops/set_hook.py new file mode 100644 index 000000000..42d1a567d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_hook.py @@ -0,0 +1,57 @@ +"""The ``set-hook`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetHook(Operation[AckResult]): + """Set or unset a tmux hook (``set-hook``). + + Parameters + ---------- + name : str + The hook name (e.g. ``after-new-window``). + hook_command : str or None + The tmux command to run (omit when *unset*). + global_ : bool + Apply globally (``-g``). + unset : bool + Unset the hook (``-u``). + + Examples + -------- + >>> SetHook(name="after-new-window", hook_command="display hi").render() + ('set-hook', 'after-new-window', 'display hi') + """ + + kind = "set_hook" + command = "set-hook" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + name: str + hook_command: str | None = None + global_: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, hook name, and command.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.unset: + out.append("-u") + out.append(self.name) + if self.hook_command is not None and not self.unset: + out.append(self.hook_command) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_option.py b/src/libtmux/experimental/ops/_ops/set_option.py new file mode 100644 index 000000000..acbb0e4d2 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_option.py @@ -0,0 +1,80 @@ +"""The ``set-option`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetOption(Operation[AckResult]): + """Set a tmux option (``set-option``); the write counterpart to show-options. + + Parameters + ---------- + option : str + The option name. + value : str or None + The value to set (omit when *unset* is true). + global_, server, window, pane : bool + Scope flags (``-g`` / ``-s`` / ``-w`` / ``-p``). + append : bool + Append to a string/array option (``-a``). + unset : bool + Unset the option (``-u``). + quiet : bool + Suppress errors (``-q``). + + Examples + -------- + >>> SetOption(option="status", value="on").render() + ('set-option', 'status', 'on') + >>> SetOption(global_=True, option="status", value="on").render() + ('set-option', '-g', 'status', 'on') + >>> SetOption(option="status", unset=True).render() + ('set-option', '-u', 'status') + """ + + kind = "set_option" + command = "set-option" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + option: str + value: str | None = None + global_: bool = False + server: bool = False + window: bool = False + pane: bool = False + append: bool = False + unset: bool = False + quiet: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the option flags, name, and value.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.global_: + out.append("-g") + if self.server: + out.append("-s") + if self.window: + out.append("-w") + if self.pane: + out.append("-p") + if self.quiet: + out.append("-q") + if self.unset: + out.append("-u") + out.append(self.option) + if self.value is not None and not self.unset: + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_window_option.py b/src/libtmux/experimental/ops/_ops/set_window_option.py new file mode 100644 index 000000000..2fb1cb2b2 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_window_option.py @@ -0,0 +1,62 @@ +"""The ``set-window-option`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetWindowOption(Operation[AckResult]): + """Set a window option (``set-window-option``). + + Parameters + ---------- + option : str + The option name. + value : str or None + The value to set (omit when *unset* is true). + global_ : bool + Apply to all windows (``-g``). + append : bool + Append to a string/array option (``-a``). + unset : bool + Unset the option (``-u``). + + Examples + -------- + >>> SetWindowOption(option="mode-keys", value="vi").render() + ('set-window-option', 'mode-keys', 'vi') + """ + + kind = "set_window_option" + command = "set-window-option" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + option: str + value: str | None = None + global_: bool = False + append: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the option flags, name, and value.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.global_: + out.append("-g") + if self.unset: + out.append("-u") + out.append(self.option) + if self.value is not None and not self.unset: + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/source_file.py b/src/libtmux/experimental/ops/_ops/source_file.py new file mode 100644 index 000000000..b288c89f4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/source_file.py @@ -0,0 +1,57 @@ +"""The ``source-file`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SourceFile(Operation[AckResult]): + """Execute tmux commands from a file (``source-file``). + + Parameters + ---------- + path : str + Path to the file to source. + quiet : bool + Suppress errors for missing files (``-q``). + verbose : bool + Show the parsed commands (``-v``). + no_exec : bool + Parse but do not execute (``-n``). + + Examples + -------- + >>> SourceFile(path="~/.tmux.conf").render() + ('source-file', '~/.tmux.conf') + """ + + kind = "source_file" + command = "source-file" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + path: str + quiet: bool = False + verbose: bool = False + no_exec: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the source-file flags and path.""" + out: list[str] = [] + if self.no_exec: + out.append("-n") + if self.quiet: + out.append("-q") + if self.verbose: + out.append("-v") + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/start_server.py b/src/libtmux/experimental/ops/_ops/start_server.py new file mode 100644 index 000000000..36a03c624 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/start_server.py @@ -0,0 +1,29 @@ +"""The ``start-server`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class StartServer(Operation[AckResult]): + """Start the tmux server if it is not already running (``start-server``). + + Examples + -------- + >>> StartServer().render() + ('start-server',) + """ + + kind = "start_server" + command = "start-server" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/suspend_client.py b/src/libtmux/experimental/ops/_ops/suspend_client.py new file mode 100644 index 000000000..0aac0b018 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/suspend_client.py @@ -0,0 +1,32 @@ +"""The ``suspend-client`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SuspendClient(Operation[AckResult]): + """Suspend a client (``suspend-client``). + + ``target`` is the client to suspend. + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> SuspendClient(target=ClientName("/dev/pts/1")).render() + ('suspend-client', '-t', '/dev/pts/1') + """ + + kind = "suspend_client" + command = "suspend-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 7eae45b61..aa59063d9 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -66,14 +66,16 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> entries = catalog() >>> [entry.kind for entry in entries] ['break_pane', 'capture_pane', 'clear_history', 'detach_client', - 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_session', - 'kill_window', 'last_pane', 'last_window', 'link_window', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'move_pane', 'move_window', - 'new_session', 'new_window', 'next_window', 'pipe_pane', 'previous_window', - 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', - 'resize_window', 'respawn_pane', 'respawn_window', 'rotate_window', - 'select_layout', 'select_pane', 'select_window', 'send_keys', 'show_options', - 'split_window', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] + 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_server', + 'kill_session', 'kill_window', 'last_pane', 'last_window', 'link_window', + 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'move_pane', + 'move_window', 'new_session', 'new_window', 'next_window', 'pipe_pane', + 'previous_window', 'refresh_client', 'rename_session', 'rename_window', + 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', + 'rotate_window', 'run_shell', 'select_layout', 'select_pane', 'select_window', + 'send_keys', 'set_environment', 'set_hook', 'set_option', 'set_window_option', + 'show_options', 'source_file', 'split_window', 'start_server', + 'suspend_client', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/tests/experimental/ops/test_lifecycle_ops.py b/tests/experimental/ops/test_lifecycle_ops.py new file mode 100644 index 000000000..f8a8d4083 --- /dev/null +++ b/tests/experimental/ops/test_lifecycle_ops.py @@ -0,0 +1,200 @@ +"""Tests for server/session lifecycle, option, and environment operations.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + KillServer, + RunShell, + SetEnvironment, + SetHook, + SetOption, + SetWindowOption, + ShowOptions, + SourceFile, + StartServer, + SuspendClient, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import ClientName, SessionId, WindowId + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="start_server", + op=StartServer(), + expected=("start-server",), + ), + RenderCase( + test_id="kill_server", + op=KillServer(), + expected=("kill-server",), + ), + RenderCase( + test_id="run_shell", + op=RunShell(command_line="echo hi"), + expected=("run-shell", "echo hi"), + ), + RenderCase( + test_id="run_shell_background", + op=RunShell(command_line="x", background=True), + expected=("run-shell", "-b", "x"), + ), + RenderCase( + test_id="source_file", + op=SourceFile(path="~/.tmux.conf"), + expected=("source-file", "~/.tmux.conf"), + ), + RenderCase( + test_id="suspend_client", + op=SuspendClient(target=ClientName("/dev/pts/1")), + expected=("suspend-client", "-t", "/dev/pts/1"), + ), + RenderCase( + test_id="set_option", + op=SetOption(option="status", value="on"), + expected=("set-option", "status", "on"), + ), + RenderCase( + test_id="set_option_global", + op=SetOption(global_=True, option="status", value="on"), + expected=("set-option", "-g", "status", "on"), + ), + RenderCase( + test_id="set_option_unset", + op=SetOption(option="status", unset=True), + expected=("set-option", "-u", "status"), + ), + RenderCase( + test_id="set_window_option", + op=SetWindowOption(option="mode-keys", value="vi"), + expected=("set-window-option", "mode-keys", "vi"), + ), + RenderCase( + test_id="set_environment", + op=SetEnvironment(name="FOO", value="bar"), + expected=("set-environment", "FOO", "bar"), + ), + RenderCase( + test_id="set_environment_unset", + op=SetEnvironment(global_=True, name="FOO", unset=True), + expected=("set-environment", "-g", "-u", "FOO"), + ), + RenderCase( + test_id="set_hook", + op=SetHook(name="after-new-window", hook_command="display hi"), + expected=("set-hook", "after-new-window", "display hi"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_lifecycle_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_lifecycle_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_set_and_show_option_live(session: Session) -> None: + """set-option writes a session option that show-options reads back.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run( + SetOption(target=SessionId(sid), option="@ops_var", value="hello"), + engine, + ).raise_for_status() + shown = run(ShowOptions(target=SessionId(sid)), engine) + assert shown.ok + assert shown.options.get("@ops_var") == "hello" + + +def test_set_window_option_and_environment_live(session: Session) -> None: + """set-window-option and set-environment succeed against real objects.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + window = session.active_window + assert sid is not None and window.window_id is not None + + assert run( + SetWindowOption(target=WindowId(window.window_id), option="@w", value="x"), + engine, + ).ok + assert run( + SetEnvironment(target=SessionId(sid), name="OPS_ENV", value="1"), + engine, + ).ok + assert run( + SetHook( + target=SessionId(sid), + name="after-new-window", + hook_command="display-message ok", + ), + engine, + ).ok + + +def test_run_shell_source_file_start_server_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """run-shell, source-file, and start-server all succeed.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + assert run(RunShell(command_line="true"), engine).ok + assert run(StartServer(), engine).ok + + conf = tmp_path / "snippet.conf" + conf.write_text("set-option -g @sourced yes\n") + assert run(SourceFile(path=str(conf)), engine).ok From 3b022954bfc1cd115b6f424d032b16f407ddbf79 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:09:20 -0500 Subject: [PATCH 022/223] Ops(feat): Add paste-buffer operations why: The paste-buffer family the ORM uses for clipboard interchange had no typed operations, leaving buffer set/load/save/paste outside the engine-driven surface. what: - Add set-buffer, delete-buffer, load-buffer, save-buffer, paste-buffer ops - Add show-buffer read op + ShowBufferResult.text (buffer contents) - Wire ops/results/exports; extend the catalog kind-enumeration and registry readonly doctests - Add test_buffer_ops.py (NamedTuple + test_id render/round-trip cases plus a live set/show/save/delete and load/paste round-trip) --- src/libtmux/experimental/ops/__init__.py | 14 ++ src/libtmux/experimental/ops/_ops/__init__.py | 12 ++ .../experimental/ops/_ops/delete_buffer.py | 44 +++++ .../experimental/ops/_ops/load_buffer.py | 49 +++++ .../experimental/ops/_ops/paste_buffer.py | 68 +++++++ .../experimental/ops/_ops/save_buffer.py | 54 ++++++ .../experimental/ops/_ops/set_buffer.py | 54 ++++++ .../experimental/ops/_ops/show_buffer.py | 69 +++++++ src/libtmux/experimental/ops/catalog.py | 19 +- src/libtmux/experimental/ops/registry.py | 3 +- src/libtmux/experimental/ops/results.py | 7 + tests/experimental/ops/test_buffer_ops.py | 169 ++++++++++++++++++ tests/experimental/ops/test_registry.py | 1 + 13 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/delete_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/load_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/paste_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/save_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/set_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/show_buffer.py create mode 100644 tests/experimental/ops/test_buffer_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index d21b099e5..e0d1a3e37 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -24,6 +24,7 @@ BreakPane, CapturePane, ClearHistory, + DeleteBuffer, DetachClient, DisplayMessage, HasSession, @@ -39,11 +40,13 @@ ListPanes, ListSessions, ListWindows, + LoadBuffer, MovePane, MoveWindow, NewSession, NewWindow, NextWindow, + PasteBuffer, PipePane, PreviousWindow, RefreshClient, @@ -55,14 +58,17 @@ RespawnWindow, RotateWindow, RunShell, + SaveBuffer, SelectLayout, SelectPane, SelectWindow, SendKeys, + SetBuffer, SetEnvironment, SetHook, SetOption, SetWindowOption, + ShowBuffer, ShowOptions, SourceFile, SplitWindow, @@ -124,6 +130,7 @@ ListSessionsResult, ListWindowsResult, Result, + ShowBufferResult, ShowOptionsResult, SplitWindowResult, status_for, @@ -146,6 +153,7 @@ "ClearHistory", "ClientName", "CreateResult", + "DeleteBuffer", "DetachClient", "DisplayMessage", "DisplayMessageResult", @@ -172,6 +180,7 @@ "ListSessionsResult", "ListWindows", "ListWindowsResult", + "LoadBuffer", "MarkedPlanner", "MovePane", "MoveWindow", @@ -185,6 +194,7 @@ "OperationError", "OperationRegistry", "PaneId", + "PasteBuffer", "PipePane", "PlanResult", "PlanStep", @@ -201,6 +211,7 @@ "RotateWindow", "RunShell", "Safety", + "SaveBuffer", "Scope", "SelectLayout", "SelectPane", @@ -208,10 +219,13 @@ "SendKeys", "SequentialPlanner", "SessionId", + "SetBuffer", "SetEnvironment", "SetHook", "SetOption", "SetWindowOption", + "ShowBuffer", + "ShowBufferResult", "ShowOptions", "ShowOptionsResult", "SlotRef", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 66e285049..17d76abab 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -10,6 +10,7 @@ from libtmux.experimental.ops._ops.break_pane import BreakPane from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.clear_history import ClearHistory +from libtmux.experimental.ops._ops.delete_buffer import DeleteBuffer from libtmux.experimental.ops._ops.detach_client import DetachClient from libtmux.experimental.ops._ops.display_message import DisplayMessage from libtmux.experimental.ops._ops.has_session import HasSession @@ -25,11 +26,13 @@ from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows +from libtmux.experimental.ops._ops.load_buffer import LoadBuffer from libtmux.experimental.ops._ops.move_pane import MovePane from libtmux.experimental.ops._ops.move_window import MoveWindow from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow from libtmux.experimental.ops._ops.next_window import NextWindow +from libtmux.experimental.ops._ops.paste_buffer import PasteBuffer from libtmux.experimental.ops._ops.pipe_pane import PipePane from libtmux.experimental.ops._ops.previous_window import PreviousWindow from libtmux.experimental.ops._ops.refresh_client import RefreshClient @@ -41,14 +44,17 @@ from libtmux.experimental.ops._ops.respawn_window import RespawnWindow from libtmux.experimental.ops._ops.rotate_window import RotateWindow from libtmux.experimental.ops._ops.run_shell import RunShell +from libtmux.experimental.ops._ops.save_buffer import SaveBuffer from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.select_pane import SelectPane from libtmux.experimental.ops._ops.select_window import SelectWindow from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.set_buffer import SetBuffer from libtmux.experimental.ops._ops.set_environment import SetEnvironment from libtmux.experimental.ops._ops.set_hook import SetHook from libtmux.experimental.ops._ops.set_option import SetOption from libtmux.experimental.ops._ops.set_window_option import SetWindowOption +from libtmux.experimental.ops._ops.show_buffer import ShowBuffer from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.source_file import SourceFile from libtmux.experimental.ops._ops.split_window import SplitWindow @@ -63,6 +69,7 @@ "BreakPane", "CapturePane", "ClearHistory", + "DeleteBuffer", "DetachClient", "DisplayMessage", "HasSession", @@ -78,11 +85,13 @@ "ListPanes", "ListSessions", "ListWindows", + "LoadBuffer", "MovePane", "MoveWindow", "NewSession", "NewWindow", "NextWindow", + "PasteBuffer", "PipePane", "PreviousWindow", "RefreshClient", @@ -94,14 +103,17 @@ "RespawnWindow", "RotateWindow", "RunShell", + "SaveBuffer", "SelectLayout", "SelectPane", "SelectWindow", "SendKeys", + "SetBuffer", "SetEnvironment", "SetHook", "SetOption", "SetWindowOption", + "ShowBuffer", "ShowOptions", "SourceFile", "SplitWindow", diff --git a/src/libtmux/experimental/ops/_ops/delete_buffer.py b/src/libtmux/experimental/ops/_ops/delete_buffer.py new file mode 100644 index 000000000..03ef19784 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/delete_buffer.py @@ -0,0 +1,44 @@ +"""The ``delete-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class DeleteBuffer(Operation[AckResult]): + """Delete a paste buffer (``delete-buffer``). + + Parameters + ---------- + buffer_name : str or None + The buffer to delete (``-b``); the most recent when omitted. + + Examples + -------- + >>> DeleteBuffer(buffer_name="b0").render() + ('delete-buffer', '-b', 'b0') + >>> DeleteBuffer().render() + ('delete-buffer',) + """ + + kind = "delete_buffer" + command = "delete-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-b`` buffer name.""" + if self.buffer_name is not None: + return ("-b", self.buffer_name) + return () diff --git a/src/libtmux/experimental/ops/_ops/load_buffer.py b/src/libtmux/experimental/ops/_ops/load_buffer.py new file mode 100644 index 000000000..f868b89d1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/load_buffer.py @@ -0,0 +1,49 @@ +"""The ``load-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LoadBuffer(Operation[AckResult]): + """Load a paste buffer from a file (``load-buffer``). + + Parameters + ---------- + path : str + The file to load (``-`` for stdin). + buffer_name : str or None + The buffer to load into (``-b``). + + Examples + -------- + >>> LoadBuffer(path="/tmp/x").render() + ('load-buffer', '/tmp/x') + >>> LoadBuffer(buffer_name="b0", path="/tmp/x").render() + ('load-buffer', '-b', 'b0', '/tmp/x') + """ + + kind = "load_buffer" + command = "load-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + path: str + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional buffer name and path.""" + out: list[str] = [] + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/paste_buffer.py b/src/libtmux/experimental/ops/_ops/paste_buffer.py new file mode 100644 index 000000000..12281b841 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/paste_buffer.py @@ -0,0 +1,68 @@ +"""The ``paste-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PasteBuffer(Operation[AckResult]): + """Paste a buffer into a pane (``paste-buffer``). + + ``target`` is the destination pane. + + Parameters + ---------- + buffer_name : str or None + The buffer to paste (``-b``); the most recent when omitted. + delete : bool + Delete the buffer after pasting (``-d``). + bracket : bool + Use bracketed paste mode (``-p``). + no_format : bool + Do not replace separators with spaces (``-r``). + separator : str or None + Separator inserted between lines (``-s``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> PasteBuffer(target=PaneId("%1")).render() + ('paste-buffer', '-t', '%1') + >>> PasteBuffer(target=PaneId("%1"), delete=True).render() + ('paste-buffer', '-t', '%1', '-d') + """ + + kind = "paste_buffer" + command = "paste-buffer" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(writes_input=True) + + buffer_name: str | None = None + delete: bool = False + bracket: bool = False + no_format: bool = False + separator: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the paste flags and buffer name.""" + out: list[str] = [] + if self.delete: + out.append("-d") + if self.bracket: + out.append("-p") + if self.no_format: + out.append("-r") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + if self.separator is not None: + out.extend(("-s", self.separator)) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/save_buffer.py b/src/libtmux/experimental/ops/_ops/save_buffer.py new file mode 100644 index 000000000..19176b8c0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/save_buffer.py @@ -0,0 +1,54 @@ +"""The ``save-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SaveBuffer(Operation[AckResult]): + """Save a paste buffer to a file (``save-buffer``). + + Parameters + ---------- + path : str + The file to write (``-`` for stdout). + buffer_name : str or None + The buffer to save (``-b``). + append : bool + Append to the file instead of overwriting it (``-a``). + + Examples + -------- + >>> SaveBuffer(path="/tmp/x").render() + ('save-buffer', '/tmp/x') + >>> SaveBuffer(buffer_name="b0", path="/tmp/x", append=True).render() + ('save-buffer', '-a', '-b', 'b0', '/tmp/x') + """ + + kind = "save_buffer" + command = "save-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects(read_only=True) + + path: str + buffer_name: str | None = None + append: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, optional buffer name, and path.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_buffer.py b/src/libtmux/experimental/ops/_ops/set_buffer.py new file mode 100644 index 000000000..e884c97a8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_buffer.py @@ -0,0 +1,54 @@ +"""The ``set-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetBuffer(Operation[AckResult]): + """Set the contents of a paste buffer (``set-buffer``). + + Parameters + ---------- + data : str + The buffer contents. + buffer_name : str or None + The buffer to set (``-b``); tmux picks a name when omitted. + append : bool + Append to the buffer instead of replacing it (``-a``). + + Examples + -------- + >>> SetBuffer(data="hello").render() + ('set-buffer', 'hello') + >>> SetBuffer(buffer_name="b0", data="hi").render() + ('set-buffer', '-b', 'b0', 'hi') + """ + + kind = "set_buffer" + command = "set-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + data: str + buffer_name: str | None = None + append: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the buffer flags and data.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.data) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/show_buffer.py b/src/libtmux/experimental/ops/_ops/show_buffer.py new file mode 100644 index 000000000..4faa33e66 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/show_buffer.py @@ -0,0 +1,69 @@ +"""The ``show-buffer`` operation (a read returning buffer contents).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ShowBufferResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ShowBuffer(Operation[ShowBufferResult]): + r"""Show the contents of a paste buffer (``show-buffer``). + + Parameters + ---------- + buffer_name : str or None + The buffer to show (``-b``); the most recent when omitted. + + Examples + -------- + >>> ShowBuffer(buffer_name="b0").render() + ('show-buffer', '-b', 'b0') + >>> ShowBuffer().build_result(returncode=0, stdout=("line1", "line2")).text + 'line1\nline2' + """ + + kind = "show_buffer" + command = "show-buffer" + scope = "server" + result_cls = ShowBufferResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-b`` buffer name.""" + if self.buffer_name is not None: + return ("-b", self.buffer_name) + return () + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ShowBufferResult: + """Join the captured lines into the buffer text.""" + return ShowBufferResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + text="\n".join(stdout), + ) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index aa59063d9..55ed6118a 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,17 +65,18 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['break_pane', 'capture_pane', 'clear_history', 'detach_client', + ['break_pane', 'capture_pane', 'clear_history', 'delete_buffer', 'detach_client', 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_server', 'kill_session', 'kill_window', 'last_pane', 'last_window', 'link_window', - 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'move_pane', - 'move_window', 'new_session', 'new_window', 'next_window', 'pipe_pane', - 'previous_window', 'refresh_client', 'rename_session', 'rename_window', - 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', - 'rotate_window', 'run_shell', 'select_layout', 'select_pane', 'select_window', - 'send_keys', 'set_environment', 'set_hook', 'set_option', 'set_window_option', - 'show_options', 'source_file', 'split_window', 'start_server', - 'suspend_client', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] + 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'load_buffer', + 'move_pane', 'move_window', 'new_session', 'new_window', 'next_window', + 'paste_buffer', 'pipe_pane', 'previous_window', 'refresh_client', 'rename_session', + 'rename_window', 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', + 'rotate_window', 'run_shell', 'save_buffer', 'select_layout', 'select_pane', + 'select_window', 'send_keys', 'set_buffer', 'set_environment', 'set_hook', + 'set_option', 'set_window_option', 'show_buffer', 'show_options', 'source_file', + 'split_window', 'start_server', 'suspend_client', 'swap_pane', 'swap_window', + 'switch_client', 'unlink_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index f610606b6..e0538ac6a 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -168,7 +168,8 @@ def select( >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'display_message', 'has_session', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'show_options'] + 'list_panes', 'list_sessions', 'list_windows', 'show_buffer', + 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 3f4073ba8..1e0dfad70 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -319,3 +319,10 @@ class ShowOptionsResult(Result): """Result of ``show-options``: parsed ``name value`` pairs in :attr:`options`.""" options: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ShowBufferResult(Result): + """Result of ``show-buffer``: the buffer contents as :attr:`text`.""" + + text: str = "" diff --git a/tests/experimental/ops/test_buffer_ops.py b/tests/experimental/ops/test_buffer_ops.py new file mode 100644 index 000000000..147b8abe8 --- /dev/null +++ b/tests/experimental/ops/test_buffer_ops.py @@ -0,0 +1,169 @@ +"""Tests for the paste-buffer operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + DeleteBuffer, + LoadBuffer, + PasteBuffer, + SaveBuffer, + SetBuffer, + ShowBuffer, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="set_buffer", + op=SetBuffer(data="hello"), + expected=("set-buffer", "hello"), + ), + RenderCase( + test_id="set_buffer_named", + op=SetBuffer(buffer_name="b0", data="hi"), + expected=("set-buffer", "-b", "b0", "hi"), + ), + RenderCase( + test_id="delete_buffer_named", + op=DeleteBuffer(buffer_name="b0"), + expected=("delete-buffer", "-b", "b0"), + ), + RenderCase( + test_id="delete_buffer_default", + op=DeleteBuffer(), + expected=("delete-buffer",), + ), + RenderCase( + test_id="load_buffer", + op=LoadBuffer(path="/tmp/x"), + expected=("load-buffer", "/tmp/x"), + ), + RenderCase( + test_id="save_buffer", + op=SaveBuffer(path="/tmp/x"), + expected=("save-buffer", "/tmp/x"), + ), + RenderCase( + test_id="save_buffer_append_named", + op=SaveBuffer(buffer_name="b0", path="/tmp/x", append=True), + expected=("save-buffer", "-a", "-b", "b0", "/tmp/x"), + ), + RenderCase( + test_id="paste_buffer", + op=PasteBuffer(target=PaneId("%1")), + expected=("paste-buffer", "-t", "%1"), + ), + RenderCase( + test_id="paste_buffer_delete", + op=PasteBuffer(target=PaneId("%1"), delete=True), + expected=("paste-buffer", "-t", "%1", "-d"), + ), + RenderCase( + test_id="show_buffer", + op=ShowBuffer(buffer_name="b0"), + expected=("show-buffer", "-b", "b0"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_buffer_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each buffer op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_buffer_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_show_buffer_joins_lines() -> None: + """show-buffer joins captured lines into the buffer text.""" + result = ShowBuffer().build_result(returncode=0, stdout=("line1", "line2")) + assert result.text == "line1\nline2" + + +def test_set_show_save_delete_buffer_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """set-buffer/show-buffer/save-buffer/delete-buffer round-trip a buffer.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + run(SetBuffer(buffer_name="ops_b", data="hello world"), engine).raise_for_status() + shown = run(ShowBuffer(buffer_name="ops_b"), engine) + assert shown.ok + assert shown.text == "hello world" + + out = tmp_path / "buf.txt" + run(SaveBuffer(buffer_name="ops_b", path=str(out)), engine).raise_for_status() + assert out.read_text() == "hello world" + + assert run(DeleteBuffer(buffer_name="ops_b"), engine).ok + + +def test_load_and_paste_buffer_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """load-buffer reads a file into a buffer; paste-buffer targets a pane.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + src = tmp_path / "in.txt" + src.write_text("pasted-content") + run(LoadBuffer(buffer_name="ops_lb", path=str(src)), engine).raise_for_status() + assert run(ShowBuffer(buffer_name="ops_lb"), engine).text == "pasted-content" + + assert run( + PasteBuffer(target=PaneId(pane.pane_id), buffer_name="ops_lb"), + engine, + ).ok diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 667656f25..6679bae71 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -53,6 +53,7 @@ def test_list_predicate_filters() -> None: "list_panes", "list_sessions", "list_windows", + "show_buffer", "show_options", ] From 5c9190356809ac72b52ef26ae188901c90450a7c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:17:53 -0500 Subject: [PATCH 023/223] docs(experimental): Document engines and lazy plans why: The experimental page described operations and the catalog but not how to run them or compose multi-step plans, leaving the engine choice and planner A/B story undocumented. what: - Add "Running an operation" (run/arun, raise_for_status policy) - Add "Choosing an engine" (engine table, create_engine, async peers) - Add "Lazy plans and planners" (LazyPlan slot refs, >> chaining, Sequential/Folding/Marked planners) - All examples are executable doctests via the in-memory ConcreteEngine --- docs/experimental.md | 99 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/experimental.md b/docs/experimental.md index 8cebf6145..0cc6fbe65 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -16,6 +16,105 @@ 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 From 948c01e4a654ff7ad072793c3cd23aadb464e3eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:18:01 -0500 Subject: [PATCH 024/223] docs(CHANGES): Note experimental operations and engines why: Record the experimental operations/engines layer for the upcoming release so the unreleased section tracks what landed. what: - Add a "What's new" deliverable under the unreleased 0.59.x section for the experimental operations and engines layer (#690) - Defer the release lead paragraph until the version is cut --- CHANGES | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGES b/CHANGES index eb0ac5620..61546b1c7 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,33 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### 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. ### Documentation #### Cleaner `from_env` examples (#719) From 4a93feb12e27c5abfff065978d91e2aec46cdf61 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:28:12 -0500 Subject: [PATCH 025/223] Ops(fix): Correct move-window -k and paste-buffer -r why: An adversarial review of the new ops against tmux's command grammar found two defects: move-window could not request its kill-on-collision behavior, and paste-buffer's -r flag was documented as a space replacement it never performs. what: - MoveWindow: add kill (-k) field; tmux move-window's option string is "abdkrs:t:" and -k replaces any window already at the destination index - PasteBuffer: rename no_format to no_replace and fix the docstring; -r keeps linefeeds instead of converting them to the default carriage-return separator (it has nothing to do with spaces) - Add render cases for move-window -k/-r and paste-buffer -r --- src/libtmux/experimental/ops/_ops/move_window.py | 5 +++++ src/libtmux/experimental/ops/_ops/paste_buffer.py | 9 +++++---- tests/experimental/ops/test_buffer_ops.py | 5 +++++ tests/experimental/ops/test_window_ops.py | 10 ++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/move_window.py b/src/libtmux/experimental/ops/_ops/move_window.py index 525f2944d..99092c6a0 100644 --- a/src/libtmux/experimental/ops/_ops/move_window.py +++ b/src/libtmux/experimental/ops/_ops/move_window.py @@ -24,6 +24,8 @@ class MoveWindow(Operation[AckResult]): Do not change the active window (``-d``). before, after : bool Insert before (``-b``) or after (``-a``) the destination index. + kill : bool + Replace (kill) any window already at the destination (``-k``). renumber : bool Renumber windows to close gaps (``-r``). @@ -44,6 +46,7 @@ class MoveWindow(Operation[AckResult]): detach: bool = False before: bool = False after: bool = False + kill: bool = False renumber: bool = False def args(self, *, version: str | None = None) -> tuple[str, ...]: @@ -55,6 +58,8 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out.append("-b") if self.after: out.append("-a") + if self.kill: + out.append("-k") if self.renumber: out.append("-r") out.extend(self.src_args()) diff --git a/src/libtmux/experimental/ops/_ops/paste_buffer.py b/src/libtmux/experimental/ops/_ops/paste_buffer.py index 12281b841..a4219058e 100644 --- a/src/libtmux/experimental/ops/_ops/paste_buffer.py +++ b/src/libtmux/experimental/ops/_ops/paste_buffer.py @@ -25,8 +25,9 @@ class PasteBuffer(Operation[AckResult]): Delete the buffer after pasting (``-d``). bracket : bool Use bracketed paste mode (``-p``). - no_format : bool - Do not replace separators with spaces (``-r``). + no_replace : bool + Do no separator replacement: keep linefeeds (LF) instead of + converting them to the default carriage-return separator (``-r``). separator : str or None Separator inserted between lines (``-s``). @@ -49,7 +50,7 @@ class PasteBuffer(Operation[AckResult]): buffer_name: str | None = None delete: bool = False bracket: bool = False - no_format: bool = False + no_replace: bool = False separator: str | None = None def args(self, *, version: str | None = None) -> tuple[str, ...]: @@ -59,7 +60,7 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out.append("-d") if self.bracket: out.append("-p") - if self.no_format: + if self.no_replace: out.append("-r") if self.buffer_name is not None: out.extend(("-b", self.buffer_name)) diff --git a/tests/experimental/ops/test_buffer_ops.py b/tests/experimental/ops/test_buffer_ops.py index 147b8abe8..d4b03e93a 100644 --- a/tests/experimental/ops/test_buffer_ops.py +++ b/tests/experimental/ops/test_buffer_ops.py @@ -82,6 +82,11 @@ class RenderCase(t.NamedTuple): op=PasteBuffer(target=PaneId("%1"), delete=True), expected=("paste-buffer", "-t", "%1", "-d"), ), + RenderCase( + test_id="paste_buffer_no_replace", + op=PasteBuffer(target=PaneId("%1"), no_replace=True), + expected=("paste-buffer", "-t", "%1", "-r"), + ), RenderCase( test_id="show_buffer", op=ShowBuffer(buffer_name="b0"), diff --git a/tests/experimental/ops/test_window_ops.py b/tests/experimental/ops/test_window_ops.py index b428998a5..cd88e732c 100644 --- a/tests/experimental/ops/test_window_ops.py +++ b/tests/experimental/ops/test_window_ops.py @@ -97,6 +97,16 @@ class RenderCase(t.NamedTuple): op=MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")), expected=("move-window", "-t", "$0", "-s", "@2"), ), + RenderCase( + test_id="move_window_kill_renumber", + op=MoveWindow( + target=SessionId("$0"), + src_target=WindowId("@2"), + kill=True, + renumber=True, + ), + expected=("move-window", "-t", "$0", "-k", "-r", "-s", "@2"), + ), RenderCase( test_id="link_window", op=LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")), From aeb278905ba8646423c43a18ac65fca164ba5c30 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:03:42 -0500 Subject: [PATCH 026/223] Ops(fix): Resolve SlotRef src_target in lazy plans why: A LazyPlan resolved a forward SlotRef only for an op's target, so a dual-target op (swap/join/move/break/link) whose src_target came from an earlier plan.add(...) reached render() with the slot unresolved and raised TypeError. serialize already handled both fields; resolution did not. what: - Factor _resolve_slot() and resolve both target and src_target in _resolve() - Add parametrized test_plan_resolves_src_target covering swap/join/ move/break panes --- src/libtmux/experimental/ops/plan.py | 33 ++++++++++++++--------- tests/experimental/ops/test_plan.py | 40 +++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 74bec9639..38c292a11 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -78,23 +78,32 @@ def _target_from_id(value: str) -> Target: return Special(value) -def _resolve( - operation: Operation[t.Any], - bindings: dict[int, str], -) -> Operation[t.Any]: - """Substitute a :class:`SlotRef` target with a captured concrete id.""" - target = operation.target - if not isinstance(target, SlotRef): - return operation +def _resolve_slot(ref: SlotRef, bindings: dict[int, str]) -> Target: + """Map a :class:`SlotRef` to the captured concrete target it points at.""" try: - concrete = bindings[target.slot] + target.suffix + concrete = bindings[ref.slot] + ref.suffix except KeyError as error: msg = ( - f"slot {target.slot} has no captured id yet; a plan step can only " - f"target an earlier step that creates an object" + f"slot {ref.slot} has no captured id yet; a plan step can only " + f"reference an earlier step that creates an object" ) raise OperationError(msg) from error - return dataclasses.replace(operation, target=_target_from_id(concrete)) + return _target_from_id(concrete) + + +def _resolve( + operation: Operation[t.Any], + bindings: dict[int, str], +) -> Operation[t.Any]: + """Substitute any :class:`SlotRef` ``target``/``src_target`` with its id.""" + changes: dict[str, Target] = {} + if isinstance(operation.target, SlotRef): + changes["target"] = _resolve_slot(operation.target, bindings) + if isinstance(operation.src_target, SlotRef): + changes["src_target"] = _resolve_slot(operation.src_target, bindings) + if not changes: + return operation + return dataclasses.replace(operation, **changes) @dataclass(frozen=True) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 317a953cb..c48ecea17 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -3,18 +3,26 @@ from __future__ import annotations import asyncio +import typing as t import pytest from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.ops import ( + BreakPane, + JoinPane, LazyPlan, + MovePane, SendKeys, SplitWindow, + SwapPane, ) -from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId from libtmux.experimental.ops.exc import OperationError +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + def test_plan_records_without_executing() -> None: """Building a plan touches no engine; it just records operations.""" @@ -38,6 +46,36 @@ def test_plan_resolves_forward_ref() -> None: assert outcome.ok +class SrcResolveCase(t.NamedTuple): + """A dual-target op whose ``src_target`` is a forward :class:`SlotRef`.""" + + test_id: str + op: Operation[t.Any] + + +SRC_RESOLVE_CASES = ( + SrcResolveCase("swap_pane", SwapPane(target=PaneId("%0"), src_target=SlotRef(0))), + SrcResolveCase("join_pane", JoinPane(target=WindowId("@0"), src_target=SlotRef(0))), + SrcResolveCase("move_pane", MovePane(target=WindowId("@0"), src_target=SlotRef(0))), + SrcResolveCase("break_pane", BreakPane(src_target=SlotRef(0))), +) + + +@pytest.mark.parametrize( + list(SrcResolveCase._fields), + SRC_RESOLVE_CASES, + ids=[c.test_id for c in SRC_RESOLVE_CASES], +) +def test_plan_resolves_src_target(test_id: str, op: Operation[t.Any]) -> None: + """A SlotRef used as ``src_target`` resolves to the captured id.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 + plan.add(op) + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert outcome.results[1].argv[-2:] == ("-s", "%1") + + def test_plan_aexecute_matches_execute() -> None: """The async driver resolves refs identically to the sync driver.""" plan = LazyPlan() From 7980da05004c9d595ce23091f5a264255d182dd6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:07:29 -0500 Subject: [PATCH 027/223] Ops(fix): Skip all decorates when a marked-fold create fails why: In a {marked} fold, when the create step failed (no captured id) attribute_marked still ran the chain attributor, which blamed the first decorate as "failed" -- but tmux stopped at the create, so no decorate ran. The first decorate was wrongly reported as the failure. what: - When new_id is None, mark every decorate "skipped" and return the create's failure (the failed-decorate path is unchanged: first blamed, rest skipped) - Add parametrized test_attribute_marked for success/create-fails/ decorate-fails --- src/libtmux/experimental/ops/_chain.py | 17 ++++--- tests/experimental/ops/test_chain.py | 68 +++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 7561047ba..36ccdc389 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -130,19 +130,20 @@ def attribute_marked( ) -> tuple[Result, list[Result], str | None]: """Split a ``{marked}`` dispatch result into the create's + decorates' results.""" new_id = merged.stdout[0].strip() if merged.stdout else None - if new_id is not None: - create_result = create.build_result( - returncode=0, stdout=(new_id,), version=version - ) - else: + # Attribute over the {marked}-retargeted decorates -- their original SlotRef + # target is unresolved and cannot render. + marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] + if new_id is None: + # The create step failed: tmux stopped, so no decorate ran -- skip them + # all rather than blaming the first. create_result = create.build_result( returncode=merged.returncode or 1, stderr=tuple(merged.stderr), version=version, ) - # Attribute over the {marked}-retargeted decorates -- their original SlotRef - # target is unresolved and cannot render. - marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] + decorated = [op.result_with_status("skipped", version=version) for op in marked] + return create_result, decorated, None + create_result = create.build_result(returncode=0, stdout=(new_id,), version=version) return create_result, attribute(marked, merged, version), new_id diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 66501299a..35a7dfe90 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -17,7 +17,11 @@ SendKeys, SplitWindow, ) -from libtmux.experimental.ops._chain import ensure_chainable, render_chain +from libtmux.experimental.ops._chain import ( + attribute_marked, + ensure_chainable, + render_chain, +) from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.exc import OperationError @@ -147,6 +151,68 @@ def test_fold_keeps_creation_ops_unfolded() -> None: assert outcome.ok +class MarkedAttrCase(t.NamedTuple): + """A merged {marked} dispatch result and the per-op statuses it yields.""" + + test_id: str + merged: CommandResult + new_id: str | None + create_status: str + decorate_statuses: list[str] + + +_MARK_CREATE = SplitWindow(target=WindowId("@1")) +_MARK_DECORATES = ( + SendKeys(target=PaneId("%9"), keys="a"), + SendKeys(target=PaneId("%9"), keys="b"), +) + +MARKED_ATTR_CASES = ( + MarkedAttrCase( + test_id="all_succeed", + merged=CommandResult(cmd=("tmux",), stdout=("%2",), returncode=0), + new_id="%2", + create_status="complete", + decorate_statuses=["complete", "complete"], + ), + MarkedAttrCase( + test_id="create_fails", + merged=CommandResult(cmd=("tmux",), returncode=1, stderr=("boom",)), + new_id=None, + create_status="failed", + decorate_statuses=["skipped", "skipped"], + ), + MarkedAttrCase( + test_id="decorate_fails", + merged=CommandResult( + cmd=("tmux",), stdout=("%2",), returncode=1, stderr=("x",) + ), + new_id="%2", + create_status="complete", + decorate_statuses=["failed", "skipped"], + ), +) + + +@pytest.mark.parametrize( + list(MarkedAttrCase._fields), + MARKED_ATTR_CASES, + ids=[c.test_id for c in MARKED_ATTR_CASES], +) +def test_attribute_marked( + test_id: str, + merged: CommandResult, + new_id: str | None, + create_status: str, + decorate_statuses: list[str], +) -> None: + """A failed create skips all decorates; a failed decorate blames the first.""" + created, decorated, got_id = attribute_marked(_MARK_CREATE, _MARK_DECORATES, merged) + assert got_id == new_id + assert created.status == create_status + assert [r.status for r in decorated] == decorate_statuses + + def test_add_chain() -> None: """A composed OpChain can be added to a plan in order.""" plan = LazyPlan() From 082a311b74490e0bf580f6af2cf57bead76b2f11 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:10:48 -0500 Subject: [PATCH 028/223] Ops(fix): Mark save-buffer readonly to match its effects why: SaveBuffer declared safety="mutating" alongside effects=Effects(read_only=True) -- contradictory. save-buffer reads a tmux buffer and writes a file; it changes no tmux state, so it is a read like its peer show-buffer. A consumer filtering on safety=="readonly" wrongly omitted it. what: - Set SaveBuffer safety="readonly" and effects idempotent=True (matches ShowBuffer) - Update the registry readonly doctest + test list - Add a parametrized invariant test: safety=="readonly" agrees with effects.read_only for every registered op --- src/libtmux/experimental/ops/_ops/save_buffer.py | 4 ++-- src/libtmux/experimental/ops/registry.py | 4 ++-- tests/experimental/ops/test_registry.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/save_buffer.py b/src/libtmux/experimental/ops/_ops/save_buffer.py index 19176b8c0..215a078a4 100644 --- a/src/libtmux/experimental/ops/_ops/save_buffer.py +++ b/src/libtmux/experimental/ops/_ops/save_buffer.py @@ -36,8 +36,8 @@ class SaveBuffer(Operation[AckResult]): command = "save-buffer" scope = "server" result_cls = AckResult - safety = "mutating" - effects = Effects(read_only=True) + safety = "readonly" + effects = Effects(read_only=True, idempotent=True) path: str buffer_name: str | None = None diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index e0538ac6a..c986e5cdc 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -168,8 +168,8 @@ def select( >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'display_message', 'has_session', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'show_buffer', - 'show_options'] + 'list_panes', 'list_sessions', 'list_windows', 'save_buffer', + 'show_buffer', 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 6679bae71..f7fac59cc 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -53,11 +53,22 @@ def test_list_predicate_filters() -> None: "list_panes", "list_sessions", "list_windows", + "save_buffer", "show_buffer", "show_options", ] +@pytest.mark.parametrize( + "spec", + list(registry.select()), + ids=[spec.kind for spec in registry.select()], +) +def test_readonly_safety_matches_read_only_effect(spec: OpSpec) -> None: + """Every op's ``safety == "readonly"`` agrees with ``effects.read_only``.""" + assert spec.effects.read_only == (spec.safety == "readonly") + + def test_register_duplicate_fails_closed() -> None: """Registering an existing kind raises unless ``replace=True``.""" local = OperationRegistry() From 3256c2af1a4ea4acca73d5041bd6fd9e7029d9a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:12:55 -0500 Subject: [PATCH 029/223] Ops(docs): Fix PipePane parameter name in docstring why: The PipePane docstring documented a `command` parameter, but the field is `command_line` (renamed to avoid the `command` classvar). A reader following the docstring would hit a TypeError. what: - Rename the docstring parameter to `command_line` (the doctest already used the correct name) --- src/libtmux/experimental/ops/_ops/pipe_pane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/pipe_pane.py b/src/libtmux/experimental/ops/_ops/pipe_pane.py index c30c45c0d..0e9b6ddec 100644 --- a/src/libtmux/experimental/ops/_ops/pipe_pane.py +++ b/src/libtmux/experimental/ops/_ops/pipe_pane.py @@ -17,7 +17,7 @@ class PipePane(Operation[AckResult]): Parameters ---------- - command : str or None + command_line : str or None Shell command to pipe to. Omit to stop an existing pipe. stdin : bool Connect the pane's input to the command (``-I``). From 819683118028e01535cefb11e2b50c5c30049414 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:15:37 -0500 Subject: [PATCH 030/223] Ops(fix): Log imsg argv as a scalar tmux_cmd field why: The imsg engine logged extra={"tmux_command_argv": list(...)}, a non-scalar value that violates the logging schema (avoid ad-hoc objects; prefer stable scalars). what: - Replace the list value with the documented scalar core key tmux_cmd holding the joined command line, in both imsg debug log calls --- src/libtmux/experimental/engines/imsg/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 00e56a9a8..34c8d9da2 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -485,7 +485,7 @@ def _run_socket_command( extra={ "tmux_protocol_version": codec.version, "tmux_identify_frames": len(identify_frames), - "tmux_command_argv": list(command_argv), + "tmux_cmd": " ".join(command_argv), }, ) @@ -518,7 +518,7 @@ def _run_socket_command( "tmux_message_pid": pid, "tmux_message_len": len(payload), "tmux_message_has_fd": frame.header.has_fd, - "tmux_command_argv": list(command_argv), + "tmux_cmd": " ".join(command_argv), }, ) if msg_type == codec.msg_version: From 216c8c10a7ce109ed187419c239c0cc578a7fa0d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:21:12 -0500 Subject: [PATCH 031/223] Ops(docs): Add doctests to the planner plan() methods why: The three pluggable dispatch strategies (Sequential/Folding/ Marked) had docstrings but no examples, the clearest gap among the methods a review flagged for missing doctests. what: - Add concise, engine-free doctests to SequentialPlanner.plan, FoldingPlanner.plan, and MarkedPlanner.plan showing the PlanStep output each produces --- src/libtmux/experimental/ops/planner.py | 38 +++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py index 928dfabe8..53f5f23aa 100644 --- a/src/libtmux/experimental/ops/planner.py +++ b/src/libtmux/experimental/ops/planner.py @@ -53,7 +53,15 @@ class SequentialPlanner: """Dispatch each operation on its own (one tmux call per op).""" def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: - """One single-op step per operation.""" + """One single-op step per operation. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> SequentialPlanner().plan([SendKeys(target=PaneId("%1"), keys="a")]) + [PlanStep(indices=(0,), marked=False)] + """ return [PlanStep((index,)) for index in range(len(operations))] @@ -79,7 +87,19 @@ class FoldingPlanner: """Fold maximal runs of chainable ops into one ``;`` dispatch each.""" def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: - """Chain consecutive chainable ops; dispatch the rest alone.""" + """Chain consecutive chainable ops; dispatch the rest alone. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> ops = [ + ... SendKeys(target=PaneId("%1"), keys="a"), + ... SendKeys(target=PaneId("%1"), keys="b"), + ... ] + >>> FoldingPlanner().plan(ops) + [PlanStep(indices=(0, 1), marked=False)] + """ return _fold_runs(operations, 0) @@ -93,7 +113,19 @@ class MarkedPlanner: """ def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: - """Emit ``{marked}`` folds where possible, else fold normally.""" + """Emit ``{marked}`` folds where possible, else fold normally. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import SlotRef, WindowId + >>> ops = [ + ... SplitWindow(target=WindowId("@1")), + ... SendKeys(target=SlotRef(0), keys="vim", enter=True), + ... ] + >>> MarkedPlanner().plan(ops) + [PlanStep(indices=(0, 1), marked=True)] + """ steps: list[PlanStep] = [] index = 0 total = len(operations) From 2405a047376cc55a1d51bfabd9dbe3e723e87aae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 15:40:16 -0500 Subject: [PATCH 032/223] Ops(fix): Resolve decorate src_target in {marked} folds why: The earlier src_target fix covered the single-op and chain dispatch paths but not the {marked} fold: _drive built decorates raw, so a chainable dual-target decorate (swap/join/move) whose src_target is a forward slot reached render_marked unresolved and raised TypeError. A decorate's target is the same-fold create (addressed via {marked}); only its src_target -- which points at an earlier bound step -- can and must be resolved. what: - Add _resolve_src() (resolves only a SlotRef src_target, leaving the {marked}-bound target to render_marked) and use it for decorates in the _drive marked branch - Add parametrized test_marked_plan_resolves_decorate_src_target (swap/join/move decorate referencing an earlier bound pane) --- src/libtmux/experimental/ops/plan.py | 23 ++++++++++++++++++- tests/experimental/ops/test_plan.py | 34 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 38c292a11..f8fb2723e 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -106,6 +106,25 @@ def _resolve( return dataclasses.replace(operation, **changes) +def _resolve_src( + operation: Operation[t.Any], + bindings: dict[int, str], +) -> Operation[t.Any]: + """Resolve only a :class:`SlotRef` ``src_target``. + + A ``{marked}`` decorate's ``target`` is this same fold's create, which has no + captured id yet -- it is addressed through tmux's ``{marked}`` register by + :func:`~._chain.render_marked`, so only ``src_target`` (which references an + already-bound earlier step) is substituted here. + """ + if isinstance(operation.src_target, SlotRef): + return dataclasses.replace( + operation, + src_target=_resolve_slot(operation.src_target, bindings), + ) + return operation + + @dataclass(frozen=True) class PlanResult: """The outcome of executing a :class:`LazyPlan`. @@ -213,7 +232,9 @@ def _drive( if step.marked: create_idx, *decorate_idx = step.indices create = _resolve(self._operations[create_idx], bindings) - decorates = [self._operations[i] for i in decorate_idx] + decorates = [ + _resolve_src(self._operations[i], bindings) for i in decorate_idx + ] merged: CommandResult = yield _Chain( render_marked(create, decorates, version), ) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index c48ecea17..812d20665 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -12,6 +12,7 @@ BreakPane, JoinPane, LazyPlan, + MarkedPlanner, MovePane, SendKeys, SplitWindow, @@ -76,6 +77,39 @@ def test_plan_resolves_src_target(test_id: str, op: Operation[t.Any]) -> None: assert outcome.results[1].argv[-2:] == ("-s", "%1") +class MarkedSrcCase(t.NamedTuple): + """A {marked} decorate whose ``src_target`` references an earlier bound slot.""" + + test_id: str + op: Operation[t.Any] + + +MARKED_SRC_CASES = ( + MarkedSrcCase("swap_pane", SwapPane(target=SlotRef(1), src_target=SlotRef(0))), + MarkedSrcCase("join_pane", JoinPane(target=SlotRef(1), src_target=SlotRef(0))), + MarkedSrcCase("move_pane", MovePane(target=SlotRef(1), src_target=SlotRef(0))), +) + + +@pytest.mark.parametrize( + list(MarkedSrcCase._fields), + MARKED_SRC_CASES, + ids=[c.test_id for c in MARKED_SRC_CASES], +) +def test_marked_plan_resolves_decorate_src_target( + test_id: str, + op: Operation[t.Any], +) -> None: + """A {marked} decorate's ``src_target`` SlotRef resolves to the bound id.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 (own dispatch) + plan.add(SplitWindow(target=WindowId("@1"))) # slot 1 -> the marked-fold creator + plan.add(op) # slot 2 -> decorate: target {marked}, src_target -> slot 0 + outcome = plan.execute(ConcreteEngine(), planner=MarkedPlanner()) + assert outcome.ok + assert outcome.results[2].argv[-2:] == ("-s", "%1") + + def test_plan_aexecute_matches_execute() -> None: """The async driver resolves refs identically to the sync driver.""" plan = LazyPlan() From 48227a12a7cebb6350be9c722d1826cf6f113f81 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 15:45:44 -0500 Subject: [PATCH 033/223] Ops(fix): Keep ; a bare separator in control-mode engines why: ControlModeEngine and AsyncControlModeEngine serialized each command with shlex.join, which quotes a folded chain's standalone ";" token to "';'". tmux -C then read it as a literal argument, so a FoldingPlanner/MarkedPlanner chain over control mode ran as one malformed command and the chained commands silently never executed. what: - Add render_control_line() to engines/base.py: shell-quote each token but leave a standalone ";" bare - Use it in both control-mode run_batch payloads (the only send path); drop the now-unused shlex imports - Add parametrized test_render_control_line (plain, quoted, chain) --- .../engines/async_control_mode.py | 6 ++- src/libtmux/experimental/engines/base.py | 18 +++++++ .../experimental/engines/control_mode.py | 7 +-- tests/experimental/engines/test_base.py | 50 +++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 tests/experimental/engines/test_base.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index ccfaafdbe..25bc00eef 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -27,12 +27,12 @@ import asyncio import collections import contextlib -import shlex import shutil import typing as t from dataclasses import dataclass from libtmux import exc +from libtmux.experimental.engines.base import render_control_line from libtmux.experimental.engines.control_mode import ( ControlModeError, ControlModeParser, @@ -211,7 +211,9 @@ async def run_batch( future: asyncio.Future[CommandResult] = loop.create_future() self._pending.append(_PendingCommand(future, argv)) futures.append(future) - payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + payload = b"".join( + (render_control_line(argv) + "\n").encode() for argv in rendered + ) try: proc.stdin.write(payload) await proc.stdin.drain() diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 9195dbb61..9c9ca86c9 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -11,6 +11,7 @@ from __future__ import annotations import enum +import shlex import typing as t from dataclasses import dataclass, field @@ -19,6 +20,23 @@ from collections.abc import Sequence +def render_control_line(argv: Sequence[str]) -> str: + """Render a tmux argv as a control-mode (``tmux -C``) command line. + + Each token is quoted for the control parser, but a standalone ``;`` separator + is left bare so a folded ``a ; b`` chain dispatches as two commands instead of + one command with a literal ``';'`` argument. + + Examples + -------- + >>> render_control_line(("rename-window", "-t", "@1", "a b")) + "rename-window -t @1 'a b'" + >>> render_control_line(("rename-window", "a", ";", "kill-window", "@2")) + 'rename-window a ; kill-window @2' + """ + return " ".join(token if token == ";" else shlex.quote(token) for token in argv) + + @dataclass(frozen=True) class CommandRequest: """A rendered tmux command, ready for an engine to execute. diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 57e4f6f88..3a92d177f 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -21,7 +21,6 @@ import logging import os import selectors -import shlex import shutil import subprocess import threading @@ -29,7 +28,7 @@ import typing as t from libtmux import exc -from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.engines.base import CommandResult, render_control_line if t.TYPE_CHECKING: import types @@ -204,7 +203,9 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: # buffered from earlier activity, so they cannot be mis-attributed # to this batch's commands. self._drain_unsolicited() - payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + payload = b"".join( + (render_control_line(argv) + "\n").encode() for argv in rendered + ) self._write(payload) blocks = self._read_blocks(len(rendered)) return [ diff --git a/tests/experimental/engines/test_base.py b/tests/experimental/engines/test_base.py new file mode 100644 index 000000000..5484f9a7d --- /dev/null +++ b/tests/experimental/engines/test_base.py @@ -0,0 +1,50 @@ +"""Tests for engine base helpers.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.base import render_control_line + + +class WireCase(t.NamedTuple): + """An argv and the control-mode wire line it should render to.""" + + test_id: str + argv: tuple[str, ...] + expected: str + + +WIRE_CASES = ( + WireCase( + test_id="plain", + argv=("rename-window", "-t", "@1", "edit"), + expected="rename-window -t @1 edit", + ), + WireCase( + test_id="quotes_spaces", + argv=("set-option", "@x", "a b"), + expected="set-option @x 'a b'", + ), + WireCase( + test_id="chain_keeps_bare_semicolon", + argv=("rename-window", "a", ";", "kill-window", "@2"), + expected="rename-window a ; kill-window @2", + ), +) + + +@pytest.mark.parametrize( + list(WireCase._fields), + WIRE_CASES, + ids=[c.test_id for c in WIRE_CASES], +) +def test_render_control_line( + test_id: str, + argv: tuple[str, ...], + expected: str, +) -> None: + """A standalone ``;`` stays a separator; other tokens are shell-quoted.""" + assert render_control_line(argv) == expected From 36bf46cdf741f87fb76bfe295ce64eea389994a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 15:51:27 -0500 Subject: [PATCH 034/223] Ops(fix): Treat a blank captured id as no id in marked folds why: attribute_marked derived new_id from stdout[0].strip() and only guarded against None. A whitespace-only capture became "" and passed the guard, so the plan would bind an empty id and later raise ValueError when Special("") was constructed during slot resolution. what: - Coerce a blank captured id to None (`... or None`) so it is never bound as "" - Add test_attribute_marked_blank_stdout_is_no_id --- src/libtmux/experimental/ops/_chain.py | 2 +- tests/experimental/ops/test_chain.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 36ccdc389..29ea11f85 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -129,7 +129,7 @@ def attribute_marked( version: str | None = None, ) -> tuple[Result, list[Result], str | None]: """Split a ``{marked}`` dispatch result into the create's + decorates' results.""" - new_id = merged.stdout[0].strip() if merged.stdout else None + new_id = (merged.stdout[0].strip() if merged.stdout else "") or None # Attribute over the {marked}-retargeted decorates -- their original SlotRef # target is unresolved and cannot render. marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 35a7dfe90..4742ab0f5 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -213,6 +213,17 @@ def test_attribute_marked( assert [r.status for r in decorated] == decorate_statuses +def test_attribute_marked_blank_stdout_is_no_id() -> None: + """A whitespace-only captured id is treated as no id (never bound as '').""" + merged = CommandResult(cmd=("tmux",), stdout=(" ",), returncode=0) + _created, _decorated, new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert new_id is None + + def test_add_chain() -> None: """A composed OpChain can be added to a plan in order.""" plan = LazyPlan() From 7c0693d7b2ef9aa22d4cca98409f1fd77e3eafe6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:31:38 -0500 Subject: [PATCH 035/223] Ops(fix): Complete a marked fold whose creator does not capture why: When a {marked} fold's creator emits no id (capture=False) but tmux succeeded, attribute_marked took the no-id branch and forced returncode `or 1`, falsely reporting the create as failed and all decorates as skipped even though every command ran. what: - In the no-id branch, when returncode==0 and no stderr, report the create complete and all decorates complete (a non-capturing success) - Add the capture_false_success case to test_attribute_marked --- src/libtmux/experimental/ops/_chain.py | 8 ++++++++ tests/experimental/ops/test_chain.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 29ea11f85..34a838765 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -134,6 +134,14 @@ def attribute_marked( # target is unresolved and cannot render. marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] if new_id is None: + if merged.returncode == 0 and not merged.stderr: + # A non-capturing creator (capture=False) succeeded but emitted no + # id; every command in the fold ran. + create_result = create.build_result(returncode=0, version=version) + decorated = [ + op.result_with_status("complete", version=version) for op in marked + ] + return create_result, decorated, None # The create step failed: tmux stopped, so no decorate ran -- skip them # all rather than blaming the first. create_result = create.build_result( diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 4742ab0f5..7cb40ff26 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -182,6 +182,13 @@ class MarkedAttrCase(t.NamedTuple): create_status="failed", decorate_statuses=["skipped", "skipped"], ), + MarkedAttrCase( + test_id="capture_false_success", + merged=CommandResult(cmd=("tmux",), returncode=0), + new_id=None, + create_status="complete", + decorate_statuses=["complete", "complete"], + ), MarkedAttrCase( test_id="decorate_fails", merged=CommandResult( From 2b7b83df11331ab81b805cc0320c828907be0f8c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:35:49 -0500 Subject: [PATCH 036/223] Ops(fix): Drop create stdout when attributing marked decorates why: On a successful create with a failing decorate, attribute_marked passed the whole merged result -- whose stdout[0] is the create's captured pane id -- to the chain attributor, so the failed decorate's result carried the pane id as its stdout instead of error output. what: - Attribute decorates over a copy of the merged result with stdout cleared, so a failed decorate is not credited with the new pane id - Add test_attribute_marked_failed_decorate_drops_create_stdout --- src/libtmux/experimental/ops/_chain.py | 5 ++++- tests/experimental/ops/test_chain.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 34a838765..bb45b8855 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -152,7 +152,10 @@ def attribute_marked( decorated = [op.result_with_status("skipped", version=version) for op in marked] return create_result, decorated, None create_result = create.build_result(returncode=0, stdout=(new_id,), version=version) - return create_result, attribute(marked, merged, version), new_id + # Attribute decorates without the create's captured id in stdout, so a failed + # decorate is not credited with the new pane id as its output. + decorated = attribute(marked, dataclasses.replace(merged, stdout=()), version) + return create_result, decorated, new_id @dataclass(frozen=True) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 7cb40ff26..5e2409bc8 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -220,6 +220,20 @@ def test_attribute_marked( assert [r.status for r in decorated] == decorate_statuses +def test_attribute_marked_failed_decorate_drops_create_stdout() -> None: + """A failed decorate is not credited with the create's captured pane id.""" + merged = CommandResult( + cmd=("tmux",), stdout=("%2",), returncode=1, stderr=("boom",) + ) + _created, decorated, _new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert decorated[0].status == "failed" + assert "%2" not in decorated[0].stdout + + def test_attribute_marked_blank_stdout_is_no_id() -> None: """A whitespace-only captured id is treated as no id (never bound as '').""" merged = CommandResult(cmd=("tmux",), stdout=(" ",), returncode=0) From b5d80f00b7884f9eee6def577e97b95c638e77bc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:39:44 -0500 Subject: [PATCH 037/223] Ops(fix): Target the concrete pane in marked decorate results why: After a {marked} fold, decorate results kept operation.target set to Special("{marked}"), the render-time register -- not the created pane. Serializing or replaying such a result would re-dispatch to whatever {marked} points at later, not the intended pane. what: - Attribute decorates over copies retargeted to PaneId(new_id), so each decorate result's operation addresses the concrete pane (render_marked still uses {marked} for the live dispatch) - Add test_attribute_marked_decorate_target_is_concrete_pane --- src/libtmux/experimental/ops/_chain.py | 11 +++++++---- tests/experimental/ops/test_chain.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index bb45b8855..2cb254109 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -20,7 +20,7 @@ import typing as t from dataclasses import dataclass -from libtmux.experimental.ops._types import Special +from libtmux.experimental.ops._types import PaneId, Special from libtmux.experimental.ops.exc import OperationError if t.TYPE_CHECKING: @@ -152,9 +152,12 @@ def attribute_marked( decorated = [op.result_with_status("skipped", version=version) for op in marked] return create_result, decorated, None create_result = create.build_result(returncode=0, stdout=(new_id,), version=version) - # Attribute decorates without the create's captured id in stdout, so a failed - # decorate is not credited with the new pane id as its output. - decorated = attribute(marked, dataclasses.replace(merged, stdout=()), version) + # Attribute over decorates retargeted to the concrete new pane (not + # ``{marked}``) so each result's operation serializes and replays to the real + # pane; drop the create's captured id from stdout so a failed decorate is not + # credited with it. + resolved = [dataclasses.replace(op, target=PaneId(new_id)) for op in decorates] + decorated = attribute(resolved, dataclasses.replace(merged, stdout=()), version) return create_result, decorated, new_id diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 5e2409bc8..d8babf1af 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -220,6 +220,17 @@ def test_attribute_marked( assert [r.status for r in decorated] == decorate_statuses +def test_attribute_marked_decorate_target_is_concrete_pane() -> None: + """Decorate results address the concrete new pane, not {marked} (for replay).""" + merged = CommandResult(cmd=("tmux",), stdout=("%2",), returncode=0) + _created, decorated, _new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert all(r.operation.target == PaneId("%2") for r in decorated) + + def test_attribute_marked_failed_decorate_drops_create_stdout() -> None: """A failed decorate is not credited with the create's captured pane id.""" merged = CommandResult( From bf2654503f14430100fa13b4759e54bab8d4cdae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:43:38 -0500 Subject: [PATCH 038/223] Ops(fix): Decode SubprocessEngine output as UTF-8 why: SubprocessEngine ran tmux with text=True but no explicit encoding, so on a non-UTF-8 locale it decoded with the platform default -- diverging from common.tmux_cmd (which pins encoding="utf-8" per #679) and breaking the docstring's byte-for-byte claim, e.g. the format separator (U+241E) could be mangled. what: - Pass encoding="utf-8" to the Popen call - Drop the unused logging import / module logger - Add test_subprocess_engine_decodes_utf8 (monkeypatched Popen kwargs) --- .../experimental/engines/subprocess.py | 4 +- tests/experimental/engines/test_subprocess.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 tests/experimental/engines/test_subprocess.py diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py index f76b59c41..4b76bd25f 100644 --- a/src/libtmux/experimental/engines/subprocess.py +++ b/src/libtmux/experimental/engines/subprocess.py @@ -11,7 +11,6 @@ from __future__ import annotations -import logging import shutil import subprocess import typing as t @@ -25,8 +24,6 @@ from libtmux.experimental.engines.base import CommandRequest -logger = logging.getLogger(__name__) - class SubprocessEngine: """Execute tmux commands by forking the tmux CLI binary. @@ -72,6 +69,7 @@ def run(self, request: CommandRequest) -> CommandResult: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", errors="backslashreplace", ) stdout, stderr = process.communicate() diff --git a/tests/experimental/engines/test_subprocess.py b/tests/experimental/engines/test_subprocess.py new file mode 100644 index 000000000..111bb2444 --- /dev/null +++ b/tests/experimental/engines/test_subprocess.py @@ -0,0 +1,39 @@ +"""Tests for the classic SubprocessEngine.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import SubprocessEngine +from libtmux.experimental.engines.base import CommandRequest + + +class _FakeProcess: + """Minimal stand-in for a Popen process.""" + + returncode = 0 + + def communicate(self) -> tuple[str, str]: + """Return empty stdout/stderr.""" + return ("", "") + + +def test_subprocess_engine_decodes_utf8(monkeypatch: pytest.MonkeyPatch) -> None: + """The engine decodes tmux output as UTF-8 (matching common.tmux_cmd).""" + captured: dict[str, t.Any] = {} + + def fake_popen(_cmd: t.Any, **kwargs: t.Any) -> _FakeProcess: + captured.update(kwargs) + return _FakeProcess() + + monkeypatch.setattr( + "libtmux.experimental.engines.subprocess.subprocess.Popen", + fake_popen, + ) + + engine = SubprocessEngine(tmux_bin="tmux") + engine.run(CommandRequest.from_args("display-message", "-p", "x")) + + assert captured["encoding"] == "utf-8" From 8d3882db4e3a66f0b6e577e632e2d55781b3254f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:47:29 -0500 Subject: [PATCH 039/223] Models(refactor): Use namespaced dataclasses.replace in snapshots why: snapshots.py imported `replace` directly from dataclasses, but the project's import convention only exempts `dataclass` and `field` from namespace imports; the rest of the experimental tree already calls dataclasses.replace via the namespace. what: - Import dataclasses and call dataclasses.replace at the 4 call sites; drop `replace` from the from-import (no behavior change) --- src/libtmux/experimental/models/snapshots.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py index 7e2f1b5e3..4af4652d0 100644 --- a/src/libtmux/experimental/models/snapshots.py +++ b/src/libtmux/experimental/models/snapshots.py @@ -16,8 +16,9 @@ from __future__ import annotations +import dataclasses import typing as t -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field if t.TYPE_CHECKING: from collections.abc import Iterable, Mapping @@ -205,7 +206,7 @@ def to_dict(self) -> dict[str, t.Any]: @classmethod def from_dict(cls, data: Mapping[str, t.Any]) -> WindowSnapshot: """Reconstruct from :meth:`to_dict` output.""" - return replace( + return dataclasses.replace( cls.from_format(data["fields"]), panes=tuple(PaneSnapshot.from_dict(p) for p in data.get("panes", [])), ) @@ -241,7 +242,7 @@ def to_dict(self) -> dict[str, t.Any]: @classmethod def from_dict(cls, data: Mapping[str, t.Any]) -> SessionSnapshot: """Reconstruct from :meth:`to_dict` output.""" - return replace( + return dataclasses.replace( cls.from_format(data["fields"]), windows=tuple(WindowSnapshot.from_dict(w) for w in data.get("windows", [])), ) @@ -309,10 +310,10 @@ def from_pane_rows( window_panes[window_id].append(PaneSnapshot.from_format(row)) sessions = tuple( - replace( + dataclasses.replace( SessionSnapshot.from_format(session_fields[session_id]), windows=tuple( - replace( + dataclasses.replace( WindowSnapshot.from_format(window_fields[window_id]), panes=tuple(window_panes[window_id]), ) From 19c95a99092503bb0601716b3ed178d4fa4a7dde Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:50:55 -0500 Subject: [PATCH 040/223] Ops(docs): Fix PipePane -o flag description why: The toggle docstring said -o stops the pipe "if already piping the same command", but tmux's -o makes no command comparison -- it only opens the pipe when no pipe is already open on the pane. what: - Reword the toggle parameter to describe -o accurately --- src/libtmux/experimental/ops/_ops/pipe_pane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/pipe_pane.py b/src/libtmux/experimental/ops/_ops/pipe_pane.py index 0e9b6ddec..1ed4c5223 100644 --- a/src/libtmux/experimental/ops/_ops/pipe_pane.py +++ b/src/libtmux/experimental/ops/_ops/pipe_pane.py @@ -24,7 +24,7 @@ class PipePane(Operation[AckResult]): stdout : bool Connect the pane's output to the command (``-O``). toggle : bool - Only toggle: stop if already piping the same command (``-o``). + Only open the pipe if no pipe is already open on the pane (``-o``). Examples -------- From 2fcd8ee87260ff9ef8f1a7dadea34d6d9218ec67 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:55:03 -0500 Subject: [PATCH 041/223] Ops(fix): Mark save-buffer mutating (it writes a file) why: save-buffer was tiered "readonly", but it writes paste-buffer contents to a filesystem path -- a side effect. Under the MCP safety vocabulary readonly means no side effects, so an auto-approve gate keyed on safety=="readonly" would wave through arbitrary file writes. effects.read_only stays true: it changes no tmux state. what: - Set SaveBuffer safety="mutating" (effects.read_only kept) - Relax the registry invariant to "readonly => read_only" (an op may be read_only w.r.t. tmux yet still mutating via an external effect) - Drop save_buffer from the readonly registry doctest and test list --- src/libtmux/experimental/ops/_ops/save_buffer.py | 4 ++-- src/libtmux/experimental/ops/registry.py | 4 ++-- tests/experimental/ops/test_registry.py | 13 +++++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/save_buffer.py b/src/libtmux/experimental/ops/_ops/save_buffer.py index 215a078a4..19176b8c0 100644 --- a/src/libtmux/experimental/ops/_ops/save_buffer.py +++ b/src/libtmux/experimental/ops/_ops/save_buffer.py @@ -36,8 +36,8 @@ class SaveBuffer(Operation[AckResult]): command = "save-buffer" scope = "server" result_cls = AckResult - safety = "readonly" - effects = Effects(read_only=True, idempotent=True) + safety = "mutating" + effects = Effects(read_only=True) path: str buffer_name: str | None = None diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index c986e5cdc..e0538ac6a 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -168,8 +168,8 @@ def select( >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'display_message', 'has_session', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'save_buffer', - 'show_buffer', 'show_options'] + 'list_panes', 'list_sessions', 'list_windows', 'show_buffer', + 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index f7fac59cc..793e2aa82 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -53,7 +53,6 @@ def test_list_predicate_filters() -> None: "list_panes", "list_sessions", "list_windows", - "save_buffer", "show_buffer", "show_options", ] @@ -64,9 +63,15 @@ def test_list_predicate_filters() -> None: list(registry.select()), ids=[spec.kind for spec in registry.select()], ) -def test_readonly_safety_matches_read_only_effect(spec: OpSpec) -> None: - """Every op's ``safety == "readonly"`` agrees with ``effects.read_only``.""" - assert spec.effects.read_only == (spec.safety == "readonly") +def test_readonly_safety_implies_read_only_effect(spec: OpSpec) -> None: + """A ``safety == "readonly"`` op must declare ``effects.read_only``. + + The converse need not hold: an op can leave tmux state unchanged + (``read_only``) yet still be ``mutating`` because of an external side effect + -- e.g. ``save-buffer`` writes a file. + """ + if spec.safety == "readonly": + assert spec.effects.read_only def test_register_duplicate_fails_closed() -> None: From 905a642629d260242a283f110e7f8efc2d472f11 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:22:49 -0500 Subject: [PATCH 042/223] Ops(fix): Correlate control-mode blocks per command and by flags why: A folded ; chain is sent as one control-mode line that tmux runs as N commands emitting N %begin/%end blocks, but the engines collected one block per request -- so a FoldingPlanner/MarkedPlanner chain reported only the first sub-command and silently swallowed later failures, and a hook-triggered (unsolicited) block arriving mid-batch was mis-attributed to a real command. what: - Add command_count() (bare ; separators + 1) and _merge_blocks() (concat stdout; fail if any sub-command block errored) - Sync run_batch reads sum(command_count) blocks and groups/merges per request; _read_blocks keeps only solicited blocks (flags == 1) - Async _PendingCommand accumulates its command_count blocks before resolving; _dispatch_block skips flags != 1 blocks - Add test_control_mode_correlation.py (command_count/_merge_blocks units + live fold failure-detection and run-all over ControlModeEngine) --- .../engines/async_control_mode.py | 33 +++-- .../experimental/engines/control_mode.py | 62 ++++++--- .../engines/test_control_mode_correlation.py | 131 ++++++++++++++++++ 3 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 tests/experimental/engines/test_control_mode_correlation.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 25bc00eef..9514a3cf8 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -29,14 +29,15 @@ import contextlib import shutil import typing as t -from dataclasses import dataclass +from dataclasses import dataclass, field from libtmux import exc from libtmux.experimental.engines.base import render_control_line from libtmux.experimental.engines.control_mode import ( ControlModeError, ControlModeParser, - _result_from_block, + _merge_blocks, + command_count, ) if t.TYPE_CHECKING: @@ -44,6 +45,7 @@ from collections.abc import AsyncIterator, Sequence from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.engines.control_mode import ControlModeBlock _READ_CHUNK = 65536 _DEFAULT_TIMEOUT = 30.0 @@ -81,6 +83,8 @@ def parse(cls, line: bytes) -> ControlNotification: class _PendingCommand: future: asyncio.Future[CommandResult] argv: tuple[str, ...] + expected: int + blocks: list[ControlModeBlock] = field(default_factory=list) class AsyncControlModeEngine: @@ -209,7 +213,9 @@ async def run_batch( raise ControlModeError(msg) for argv in rendered: future: asyncio.Future[CommandResult] = loop.create_future() - self._pending.append(_PendingCommand(future, argv)) + self._pending.append( + _PendingCommand(future, argv, command_count(argv)), + ) futures.append(future) payload = b"".join( (render_control_line(argv) + "\n").encode() for argv in rendered @@ -305,13 +311,24 @@ async def _reader(self) -> None: except Exception as error: self._mark_dead(ControlModeError(f"control-mode reader failed: {error}")) - def _dispatch_block(self, block: t.Any) -> None: - """Resolve the next pending command, or skip an unsolicited block.""" + def _dispatch_block(self, block: ControlModeBlock) -> None: + """Accumulate a solicited block; resolve the command once it has them all. + + A ``;``-folded command emits one block per sub-command; unsolicited blocks + (hook-triggered commands, the startup ACK) carry flags 0 and are skipped, + so FIFO correlation never desyncs. + """ + if block.flags != 1: + return # unsolicited (hook-triggered command or startup ACK): skip if not self._pending: - return # startup ACK or hook-triggered command: not ours, skip - pending = self._pending.popleft() + return + pending = self._pending[0] + pending.blocks.append(block) + if len(pending.blocks) < pending.expected: + return + self._pending.popleft() if not pending.future.done(): - pending.future.set_result(_result_from_block(block, pending.argv)) + pending.future.set_result(_merge_blocks(pending.blocks, pending.argv)) def _publish(self, line: bytes) -> None: """Enqueue a notification, dropping the oldest on overflow.""" diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 3a92d177f..591812f06 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -193,10 +193,15 @@ def run(self, request: CommandRequest) -> CommandResult: return self.run_batch([request])[0] def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: - """Pipeline a batch of commands; one result block per request.""" + """Pipeline a batch of commands; one result per request. + + A ``;``-folded request runs as several tmux commands, so its blocks are + grouped (by ``;``-count) and merged into one result. + """ if not requests: return [] rendered = [tuple(req.args) for req in requests] + counts = [command_count(argv) for argv in rendered] with self._lock: self._ensure_started() # Discard any unsolicited blocks (hook-triggered commands) left @@ -207,11 +212,13 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: (render_control_line(argv) + "\n").encode() for argv in rendered ) self._write(payload) - blocks = self._read_blocks(len(rendered)) - return [ - _result_from_block(block, argv) - for block, argv in zip(blocks, rendered, strict=True) - ] + blocks = self._read_blocks(sum(counts)) + results: list[CommandResult] = [] + index = 0 + for argv, count in zip(rendered, counts, strict=True): + results.append(_merge_blocks(blocks[index : index + count], argv)) + index += count + return results def close(self) -> None: """Tear down the control-mode subprocess (lock-guarded).""" @@ -366,9 +373,10 @@ def _read_blocks(self, count: int) -> list[ControlModeBlock]: elif key.data == "stderr": self._read_stderr() for block in self._parser.blocks(): - blocks.append(block) - if len(blocks) == count: - break + # Skip unsolicited blocks (hook-triggered commands carry flags 0); + # only solicited command blocks (flags 1) belong to this batch. + if block.flags == 1 and len(blocks) < count: + blocks.append(block) self._parser.notifications() # sync engine ignores notifications return blocks @@ -453,16 +461,38 @@ def _matches_pending_close(line: bytes, pending_number: int) -> bool: return False -def _result_from_block( - block: ControlModeBlock, +def command_count(argv: tuple[str, ...]) -> int: + """How many tmux commands a rendered argv runs (bare ``;`` separators + 1).""" + return sum(1 for token in argv if token == ";") + 1 + + +def _merge_blocks( + blocks: Sequence[ControlModeBlock], argv: tuple[str, ...], ) -> CommandResult: - """Convert a parsed control-mode block into a :class:`CommandResult`.""" - lines = tuple(line.decode(errors="replace") for line in block.body) + """Merge one request's blocks (one per ``;``-folded sub-command) into a result. + + A ``;``-folded line runs as several tmux commands, each emitting its own + block; stdout/stderr are concatenated and the result fails if any sub-command + errored, matching the subprocess engine's view of one ``;`` chain process. + """ cmd = ("tmux", "-C", *argv) - if block.is_error: - return CommandResult(cmd=cmd, stdout=(), stderr=_trim(lines), returncode=1) - return CommandResult(cmd=cmd, stdout=_trim(lines), stderr=(), returncode=0) + stdout: list[str] = [] + stderr: list[str] = [] + returncode = 0 + for block in blocks: + lines = tuple(line.decode(errors="replace") for line in block.body) + if block.is_error: + stderr.extend(lines) + returncode = returncode or 1 + else: + stdout.extend(lines) + return CommandResult( + cmd=cmd, + stdout=_trim(tuple(stdout)), + stderr=_trim(tuple(stderr)), + returncode=returncode, + ) def _trim(lines: tuple[str, ...]) -> tuple[str, ...]: diff --git a/tests/experimental/engines/test_control_mode_correlation.py b/tests/experimental/engines/test_control_mode_correlation.py new file mode 100644 index 000000000..e6c68fc76 --- /dev/null +++ b/tests/experimental/engines/test_control_mode_correlation.py @@ -0,0 +1,131 @@ +"""Tests for control-mode block correlation (folded chains, merge).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.control_mode import ( + ControlModeBlock, + _merge_blocks, + command_count, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class CountCase(t.NamedTuple): + """An argv and the number of tmux commands it runs.""" + + test_id: str + argv: tuple[str, ...] + expected: int + + +COUNT_CASES = ( + CountCase("single", ("rename-window", "-t", "@1", "a"), 1), + CountCase("two", ("rename-window", "a", ";", "kill-window", "@2"), 2), + CountCase("three", ("a", ";", "b", ";", "c"), 3), + CountCase("literal_semicolon_arg", ("send-keys", "-t", "%1", "a;b"), 1), +) + + +@pytest.mark.parametrize( + list(CountCase._fields), + COUNT_CASES, + ids=[c.test_id for c in COUNT_CASES], +) +def test_command_count(test_id: str, argv: tuple[str, ...], expected: int) -> None: + """Only a standalone ``;`` token counts as a command separator.""" + assert command_count(argv) == expected + + +def _block(*, is_error: bool, body: tuple[bytes, ...]) -> ControlModeBlock: + return ControlModeBlock(number=1, flags=1, is_error=is_error, body=body) + + +class MergeCase(t.NamedTuple): + """Blocks from one (possibly folded) request and the merged result.""" + + test_id: str + blocks: list[ControlModeBlock] + returncode: int + stdout: tuple[str, ...] + stderr: tuple[str, ...] + + +MERGE_CASES = ( + MergeCase("single_ok", [_block(is_error=False, body=(b"%1",))], 0, ("%1",), ()), + MergeCase("single_err", [_block(is_error=True, body=(b"boom",))], 1, (), ("boom",)), + MergeCase( + "chain_all_ok", + [_block(is_error=False, body=(b"a",)), _block(is_error=False, body=(b"b",))], + 0, + ("a", "b"), + (), + ), + MergeCase( + "chain_second_fails", + [_block(is_error=False, body=(b"a",)), _block(is_error=True, body=(b"boom",))], + 1, + ("a",), + ("boom",), + ), +) + + +@pytest.mark.parametrize( + list(MergeCase._fields), + MERGE_CASES, + ids=[c.test_id for c in MERGE_CASES], +) +def test_merge_blocks( + test_id: str, + blocks: list[ControlModeBlock], + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], +) -> None: + """A folded request's blocks merge; any sub-command error fails the result.""" + result = _merge_blocks(blocks, ("cmd",)) + assert result.returncode == returncode + assert result.stdout == stdout + assert result.stderr == stderr + + +def test_control_mode_fold_detects_failure_live(session: Session) -> None: + """A folded chain over control mode surfaces a later sub-command's failure.""" + from libtmux.experimental.engines.control_mode import ControlModeEngine + from libtmux.experimental.ops import FoldingPlanner, LazyPlan, RenameWindow + from libtmux.experimental.ops._types import WindowId + + window = session.active_window + assert window.window_id is not None + with ControlModeEngine.for_server(session.server) as engine: + plan = LazyPlan() + plan.add(RenameWindow(target=WindowId(window.window_id), name="ok")) + plan.add(RenameWindow(target=WindowId("@999999"), name="x")) # bad target + outcome = plan.execute(engine, planner=FoldingPlanner()) + # The second sub-command's failure is no longer swallowed (was reported ok). + assert not outcome.ok + + +def test_control_mode_fold_runs_all_live(session: Session) -> None: + """A folded chain over control mode runs every sub-command.""" + from libtmux.experimental.engines.control_mode import ControlModeEngine + from libtmux.experimental.ops import FoldingPlanner, LazyPlan, RenameWindow + from libtmux.experimental.ops._types import WindowId + + second = session.new_window(window_name="orig") + first = session.active_window + assert first.window_id is not None and second.window_id is not None + with ControlModeEngine.for_server(session.server) as engine: + plan = LazyPlan() + plan.add(RenameWindow(target=WindowId(first.window_id), name="one")) + plan.add(RenameWindow(target=WindowId(second.window_id), name="two")) + outcome = plan.execute(engine, planner=FoldingPlanner()) + assert outcome.ok + second.refresh() + assert second.window_name == "two" From 9006caa579ca93ede141125f4699c51b82470f9a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:27:49 -0500 Subject: [PATCH 043/223] Ops(fix): Clear pending futures on async control-mode write failure why: AsyncControlModeEngine.run_batch appended one future per request to the FIFO before writing; if the stdin write/drain failed it raised but left those futures queued, so the next batch's result blocks resolved against the orphans -- permanently desyncing correlation. what: - On a write/drain failure, remove the just-queued futures from _pending and fail them with the ControlModeError before raising - Add test_async_control_write_failure_clears_pending (fake proc whose stdin.write raises; asserts _pending is emptied) --- .../engines/async_control_mode.py | 18 ++++++++---- .../engines/test_control_mode_correlation.py | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 9514a3cf8..c9939c52d 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -211,11 +211,12 @@ async def run_batch( if proc is None or proc.stdin is None: msg = "control-mode subprocess is not connected" raise ControlModeError(msg) + appended: list[_PendingCommand] = [] for argv in rendered: future: asyncio.Future[CommandResult] = loop.create_future() - self._pending.append( - _PendingCommand(future, argv, command_count(argv)), - ) + pending = _PendingCommand(future, argv, command_count(argv)) + self._pending.append(pending) + appended.append(pending) futures.append(future) payload = b"".join( (render_control_line(argv) + "\n").encode() for argv in rendered @@ -224,8 +225,15 @@ async def run_batch( proc.stdin.write(payload) await proc.stdin.drain() except (BrokenPipeError, OSError) as error: - msg = f"tmux control-mode write failed: {error}" - raise ControlModeError(msg) from error + # Remove the futures we just queued so a write failure cannot + # leave orphans that desync FIFO correlation for the next batch. + cm_error = ControlModeError(f"tmux control-mode write failed: {error}") + for queued in appended: + with contextlib.suppress(ValueError): + self._pending.remove(queued) + if not queued.future.done(): + queued.future.set_exception(cm_error) + raise cm_error from error try: return await asyncio.wait_for( diff --git a/tests/experimental/engines/test_control_mode_correlation.py b/tests/experimental/engines/test_control_mode_correlation.py index e6c68fc76..6cd20d77c 100644 --- a/tests/experimental/engines/test_control_mode_correlation.py +++ b/tests/experimental/engines/test_control_mode_correlation.py @@ -95,6 +95,34 @@ def test_merge_blocks( assert result.stderr == stderr +def test_async_control_write_failure_clears_pending() -> None: + """A write failure removes the queued futures so the FIFO stays aligned.""" + import asyncio + + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + from libtmux.experimental.engines.base import CommandRequest + from libtmux.experimental.engines.control_mode import ControlModeError + + class _FakeStdin: + def write(self, _data: bytes) -> None: + raise BrokenPipeError + + async def drain(self) -> None: ... + + class _FakeProc: + stdin = _FakeStdin() + + async def _check() -> None: + engine = AsyncControlModeEngine() + engine._started = True + engine._proc = t.cast("t.Any", _FakeProc()) + with pytest.raises(ControlModeError): + await engine.run_batch([CommandRequest.from_args("list-sessions")]) + assert not engine._pending + + asyncio.run(_check()) + + def test_control_mode_fold_detects_failure_live(session: Session) -> None: """A folded chain over control mode surfaces a later sub-command's failure.""" from libtmux.experimental.engines.control_mode import ControlModeEngine From 3fba6a6de7ebce56c1aaf2315b2c7b81b8c5f367 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:33:38 -0500 Subject: [PATCH 044/223] Ops(fix): Suppress ProcessLookupError on async cancel terminate why: AsyncSubprocessEngine.run() called process.terminate() unguarded in its CancelledError handler; if the child already exited, terminate raised ProcessLookupError, masking the cancellation (every sibling engine suppresses it). what: - Wrap process.terminate() in contextlib.suppress(ProcessLookupError) so CancelledError propagates - Add test_async_run_cancellation_suppresses_terminate_lookup (fake process whose terminate() raises ProcessLookupError) --- src/libtmux/experimental/engines/asyncio.py | 6 +++- .../contract/test_async_engine.py | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py index 3880efd36..bd57987e5 100644 --- a/src/libtmux/experimental/engines/asyncio.py +++ b/src/libtmux/experimental/engines/asyncio.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import contextlib import shutil import typing as t @@ -82,7 +83,10 @@ async def run(self, request: CommandRequest) -> CommandResult: try: stdout_bytes, stderr_bytes = await process.communicate() except asyncio.CancelledError: - process.terminate() + # The child may have already exited (terminate races the reap); + # suppress so the cancellation propagates, not ProcessLookupError. + with contextlib.suppress(ProcessLookupError): + process.terminate() await process.wait() raise diff --git a/tests/experimental/contract/test_async_engine.py b/tests/experimental/contract/test_async_engine.py index 1c37eb07e..95581b4ff 100644 --- a/tests/experimental/contract/test_async_engine.py +++ b/tests/experimental/contract/test_async_engine.py @@ -9,6 +9,8 @@ import asyncio import typing as t +import pytest + from libtmux.experimental.engines import AsyncSubprocessEngine, SubprocessEngine from libtmux.experimental.ops import SplitWindow, arun, run from libtmux.experimental.ops._types import WindowId @@ -18,6 +20,37 @@ from libtmux.session import Session +def test_async_run_cancellation_suppresses_terminate_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cancellation propagates even when terminate() races a process exit.""" + from libtmux.experimental.engines.base import CommandRequest + + class _FakeProc: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + raise asyncio.CancelledError + + def terminate(self) -> None: + raise ProcessLookupError + + async def wait(self) -> int: + return 0 + + async def _fake_exec(*_args: object, **_kwargs: object) -> _FakeProc: + return _FakeProc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + engine = AsyncSubprocessEngine(tmux_bin="tmux") + + async def _check() -> None: + with pytest.raises(asyncio.CancelledError): + await engine.run(CommandRequest.from_args("display-message", "-p", "x")) + + asyncio.run(_check()) + + def test_async_split_creates_real_pane(session: Session) -> None: """An async split returns a typed result whose new pane really exists.""" server = session.server From efda68be3d06bcae0c8ec79544f41c8751229766 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:38:14 -0500 Subject: [PATCH 045/223] Engines(fix): Remove the unreachable asyncio engine kind why: EngineKind.ASYNCIO and EngineSpec.asyncio() were first-class public API but never registered, so create_engine("asyncio") failed closed. The async engines are constructed directly (ctor / for_server) and bypass the sync registry; the kind was a false promise. what: - Drop EngineKind.ASYNCIO and EngineSpec.asyncio() (referenced nowhere) - Add test_registry.py covering available_engines, the removed kind, create_engine for registered kinds, and unknown-name failure --- src/libtmux/experimental/engines/base.py | 6 -- tests/experimental/engines/test_registry.py | 63 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 tests/experimental/engines/test_registry.py diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 9c9ca86c9..8516eb028 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -103,7 +103,6 @@ class EngineKind(str, enum.Enum): SUBPROCESS = "subprocess" CONCRETE = "concrete" CONTROL_MODE = "control_mode" - ASYNCIO = "asyncio" IMSG = "imsg" @@ -150,11 +149,6 @@ def control_mode(cls) -> EngineSpec: """Build a control-mode engine spec.""" return cls(kind=EngineKind.CONTROL_MODE) - @classmethod - def asyncio(cls) -> EngineSpec: - """Build an asyncio engine spec.""" - return cls(kind=EngineKind.ASYNCIO) - @classmethod def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec: """Build an imsg (native binary) engine spec.""" diff --git a/tests/experimental/engines/test_registry.py b/tests/experimental/engines/test_registry.py new file mode 100644 index 000000000..7e315cc60 --- /dev/null +++ b/tests/experimental/engines/test_registry.py @@ -0,0 +1,63 @@ +"""Tests for the engine registry and EngineKind/EngineSpec.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux import exc +from libtmux.experimental.engines import ( + EngineKind, + EngineSpec, + available_engines, + create_engine, +) + + +def test_available_engines_are_registered() -> None: + """The registry exposes exactly the constructable (sync) engine kinds.""" + assert set(available_engines()) == { + "subprocess", + "concrete", + "control_mode", + "imsg", + } + + +def test_asyncio_kind_removed() -> None: + """The unwired ``asyncio`` kind/spec is gone; async engines are direct-ctor.""" + assert "asyncio" not in {kind.value for kind in EngineKind} + assert not hasattr(EngineSpec, "asyncio") + + +class CreateCase(t.NamedTuple): + """A registered engine name that ``create_engine`` should build.""" + + test_id: str + name: str + + +CREATE_CASES = ( + CreateCase("subprocess", "subprocess"), + CreateCase("concrete", "concrete"), + CreateCase("control_mode", "control_mode"), +) + + +@pytest.mark.parametrize( + list(CreateCase._fields), + CREATE_CASES, + ids=[c.test_id for c in CREATE_CASES], +) +def test_create_engine_builds_registered(test_id: str, name: str) -> None: + """create_engine returns an engine with the run/run_batch protocol.""" + engine = create_engine(name) + assert hasattr(engine, "run") + assert hasattr(engine, "run_batch") + + +def test_create_engine_unknown_fails() -> None: + """An unregistered name (incl. the removed 'asyncio') fails closed.""" + with pytest.raises(exc.LibTmuxException, match="unknown tmux engine"): + create_engine("asyncio") From c9b0e71456b32133f4cccc98fab68edeb7026ec5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:44:16 -0500 Subject: [PATCH 046/223] Ops(fix): Normalize tmux master version in operation gates why: Operation.check_version/flag_available compared with bare LooseVersion, so a "master"/suffixed tmux version (e.g. "3.7-master" from get_version) sorted below a higher gate -- wrongly rejecting an op or dropping a supported flag. neo._normalize_tmux_version already maps master to a high sentinel. what: - Use neo._normalize_tmux_version in check_version and flag_available (drop the bare LooseVersion import) - Add a flag gate to the _FutureOp test helper + parametrized test_version_gates_normalize_master (master/suffixed/exact/old/none) --- src/libtmux/experimental/ops/operation.py | 6 ++-- tests/experimental/ops/test_operation.py | 39 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index 84c2bdd30..ae947cf52 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -19,10 +19,10 @@ import typing as t from dataclasses import dataclass -from libtmux._compat import LooseVersion from libtmux.experimental.ops._types import render_target from libtmux.experimental.ops.exc import VersionUnsupported from libtmux.experimental.ops.results import Result, status_for +from libtmux.neo import _normalize_tmux_version if t.TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -162,7 +162,7 @@ def check_version(self, version: str | None) -> None: """ if version is None or self.min_version is None: return - if LooseVersion(version) < LooseVersion(self.min_version): + if _normalize_tmux_version(version) < _normalize_tmux_version(self.min_version): raise VersionUnsupported( self.kind, need=self.min_version, @@ -197,7 +197,7 @@ def flag_available(self, label: str, version: str | None) -> bool: need = self.flag_version_map.get(label) if need is None or version is None: return True - return LooseVersion(version) >= LooseVersion(need) + return _normalize_tmux_version(version) >= _normalize_tmux_version(need) def src_args(self) -> tuple[str, ...]: """Render the ``-s`` source target, or ``()`` when there is none. diff --git a/tests/experimental/ops/test_operation.py b/tests/experimental/ops/test_operation.py index 80320c732..39b14529e 100644 --- a/tests/experimental/ops/test_operation.py +++ b/tests/experimental/ops/test_operation.py @@ -29,6 +29,7 @@ class _FutureOp(Operation[Result]): result_cls = Result effects = Effects() min_version = "99.0" + flag_version_map: t.ClassVar[dict[str, str]] = {"feat": "99.0"} def test_render_includes_target_then_args() -> None: @@ -65,6 +66,44 @@ def test_check_version_passes_when_satisfied() -> None: assert op.render(version="99.0") == ("future-cmd",) +class VersionCase(t.NamedTuple): + """A tmux version string and whether the 99.0-gated op accepts it.""" + + test_id: str + version: str | None + satisfied: bool + + +VERSION_CASES = ( + VersionCase("master_suffix", "3.7-master", True), + VersionCase("bare_master", "master", True), + VersionCase("none", None, True), + VersionCase("exact", "99.0", True), + VersionCase("too_old", "3.4", False), +) + + +@pytest.mark.parametrize( + list(VersionCase._fields), + VERSION_CASES, + ids=[c.test_id for c in VERSION_CASES], +) +def test_version_gates_normalize_master( + test_id: str, + version: str | None, + satisfied: bool, +) -> None: + """A "master"/suffixed version sorts above tagged releases for both gates.""" + op = _FutureOp() + if satisfied: + op.check_version(version) # no raise + assert op.flag_available("feat", version) is True + else: + with pytest.raises(VersionUnsupported): + op.check_version(version) + assert op.flag_available("feat", version) is False + + def test_build_result_parses_payload() -> None: """``split-window`` parses the captured new-pane id into its result.""" op = SplitWindow(target=WindowId("@1")) From 562e48ae92679eca3d461844c9f50bb02ace6552 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:49:41 -0500 Subject: [PATCH 047/223] Ops(fix): Reject SendKeys literal+enter combination why: SendKeys(literal=True, enter=True) rendered 'send-keys -l Enter', but tmux's -l sends every arg literally, so "Enter" was typed as five characters and the line was never submitted. what: - __post_init__ raises ValueError on literal+enter (fail closed); the correct pattern is two operations - Document the constraint on the enter parameter - Add parametrized test_send_keys_literal_enter_guard --- .../experimental/ops/_ops/send_keys.py | 13 ++++++- tests/experimental/ops/test_ack_ops.py | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py index e7c009796..3f4cd4c54 100644 --- a/src/libtmux/experimental/ops/_ops/send_keys.py +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -20,7 +20,9 @@ class SendKeys(Operation[AckResult]): keys : str The key string to send. enter : bool - Append a literal ``Enter`` key after the input. + Append an ``Enter`` key after the input. Cannot be combined with + *literal* -- under ``-l`` tmux would type the text "Enter" rather than + pressing Return; send the keys and Enter as two operations instead. literal : bool Send keys literally without tmux key-name lookup (``-l``). @@ -44,6 +46,15 @@ class SendKeys(Operation[AckResult]): enter: bool = False literal: bool = False + def __post_init__(self) -> None: + """Reject literal+enter (fail closed): tmux ``-l`` types "Enter".""" + if self.literal and self.enter: + msg = ( + "send-keys cannot combine literal=True with enter=True; under -l " + "tmux types the text 'Enter' -- send the keys and Enter separately" + ) + raise ValueError(msg) + def args(self, *, version: str | None = None) -> tuple[str, ...]: """Render ``send-keys`` flags and the key string.""" out: list[str] = [] diff --git a/tests/experimental/ops/test_ack_ops.py b/tests/experimental/ops/test_ack_ops.py index 2ccc63ee8..3e03d8b54 100644 --- a/tests/experimental/ops/test_ack_ops.py +++ b/tests/experimental/ops/test_ack_ops.py @@ -91,3 +91,40 @@ def test_destructive_safety_metadata() -> None: assert safety["kill_window"] == "destructive" assert safety["kill_pane"] == "destructive" assert safety["rename_window"] == "mutating" + + +class SendKeysGuardCase(t.NamedTuple): + """A literal/enter combination and whether SendKeys rejects it.""" + + test_id: str + literal: bool + enter: bool + raises: bool + + +SEND_KEYS_GUARD_CASES = ( + SendKeysGuardCase("plain", literal=False, enter=False, raises=False), + SendKeysGuardCase("enter_only", literal=False, enter=True, raises=False), + SendKeysGuardCase("literal_only", literal=True, enter=False, raises=False), + SendKeysGuardCase("literal_and_enter", literal=True, enter=True, raises=True), +) + + +@pytest.mark.parametrize( + list(SendKeysGuardCase._fields), + SEND_KEYS_GUARD_CASES, + ids=[c.test_id for c in SEND_KEYS_GUARD_CASES], +) +def test_send_keys_literal_enter_guard( + test_id: str, + literal: bool, + enter: bool, + raises: bool, +) -> None: + """literal=True with enter=True is rejected (tmux -l would type 'Enter').""" + if raises: + with pytest.raises(ValueError, match="literal"): + SendKeys(target=PaneId("%1"), keys="x", literal=literal, enter=enter) + else: + op = SendKeys(target=PaneId("%1"), keys="x", literal=literal, enter=enter) + assert op.render()[0] == "send-keys" From fbab12e1c34b88d6274b7180a3c89f4f2f01377d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:53:24 -0500 Subject: [PATCH 048/223] Ops(fix): Keep all lines of a display-message result why: DisplayMessageResult.text took only stdout[0], silently dropping all but the first line of a multi-line display-message format. what: - Join all stdout lines into .text (matching ShowBuffer); single-line output is unchanged - Add a multi-line parse case to test_read_breadth --- src/libtmux/experimental/ops/_ops/display_message.py | 4 ++-- tests/experimental/ops/test_read_breadth.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/display_message.py b/src/libtmux/experimental/ops/_ops/display_message.py index 68821960a..38ec8052c 100644 --- a/src/libtmux/experimental/ops/_ops/display_message.py +++ b/src/libtmux/experimental/ops/_ops/display_message.py @@ -53,7 +53,7 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> DisplayMessageResult: - """Expose the printed line as :attr:`~.DisplayMessageResult.text`.""" + """Expose the printed output as :attr:`~.DisplayMessageResult.text`.""" return DisplayMessageResult( operation=self, argv=argv, @@ -61,5 +61,5 @@ def _make_result( returncode=returncode, stdout=stdout, stderr=stderr, - text=stdout[0] if stdout else "", + text="\n".join(stdout), ) diff --git a/tests/experimental/ops/test_read_breadth.py b/tests/experimental/ops/test_read_breadth.py index 276ff9052..486971b81 100644 --- a/tests/experimental/ops/test_read_breadth.py +++ b/tests/experimental/ops/test_read_breadth.py @@ -112,6 +112,13 @@ class ParseCase(t.NamedTuple): stdout=("%1",), expected={"text": "%1"}, ), + ParseCase( + test_id="display_message_multiline", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=("line1", "line2"), + expected={"text": "line1\nline2"}, + ), ParseCase( test_id="display_message_empty", op=DisplayMessage(message="#{pane_id}"), From e1189ad7e7ec6956e883367884a8630543682e41 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:58:11 -0500 Subject: [PATCH 049/223] Ops(fix): Centralize the has-session stderr->stdout fold in the op why: subprocess/asyncio/imsg each folded has-session's stderr into stdout but control mode did not, so HasSession's result diverged by engine. The fold is a has-session concern, not an engine concern. what: - HasSession._make_result surfaces stderr[0] in stdout when stdout is empty, so every engine yields a consistent result - Remove the per-engine `"has-session" in cmd` fold from subprocess, asyncio, imsg; soften the subprocess/asyncio docstrings accordingly - Add test_has_session_folds_stderr_to_stdout --- src/libtmux/experimental/engines/asyncio.py | 7 ++----- src/libtmux/experimental/engines/imsg/base.py | 2 -- src/libtmux/experimental/engines/subprocess.py | 13 +++++-------- src/libtmux/experimental/ops/_ops/has_session.py | 9 ++++++++- tests/experimental/ops/test_read_breadth.py | 11 +++++++++++ 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py index bd57987e5..7ce87d0d8 100644 --- a/src/libtmux/experimental/engines/asyncio.py +++ b/src/libtmux/experimental/engines/asyncio.py @@ -4,8 +4,8 @@ not a thread wrapper around the sync engine. On cancellation it terminates the child process before propagating :class:`asyncio.CancelledError`, so a cancelled ``arun`` leaks no tmux process. It mirrors the classic engine's output handling -(``backslashreplace`` decoding, trailing-blank stripping, ``has-session`` fold) -so it returns the *same* typed result the classic engine does. +(``backslashreplace`` decoding, trailing-blank stripping) so it returns the +*same* typed result the classic engine does. """ from __future__ import annotations @@ -98,9 +98,6 @@ async def run(self, request: CommandRequest) -> CommandResult: stdout_lines.pop() stderr_lines = [line for line in stderr.split("\n") if line] - if "has-session" in cmd and stderr_lines and not stdout_lines: - stdout_lines = [stderr_lines[0]] - return CommandResult( cmd=tuple(cmd), stdout=tuple(stdout_lines), diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 34c8d9da2..d447bc40b 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -608,8 +608,6 @@ def _run_socket_command( stderr_lines = _split_output(bytes(stderr_buffer)) if exit_message: stderr_lines.append(exit_message) - if "has-session" in cmd and stderr_lines and not stdout_lines: - stdout_lines = [stderr_lines[0]] return CommandResult( cmd=tuple(cmd), diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py index 4b76bd25f..639d829e3 100644 --- a/src/libtmux/experimental/engines/subprocess.py +++ b/src/libtmux/experimental/engines/subprocess.py @@ -1,10 +1,10 @@ """The classic subprocess engine. -Executes tmux via the CLI binary, one fork per command, reproducing today's -:class:`libtmux.common.tmux_cmd` behaviour byte-for-byte: ``backslashreplace`` -decoding, trailing-blank stripping, and the ``has-session`` stderr-into-stdout -fold. A tmux-side failure is returned as data (nonzero ``returncode`` plus -``stderr``); only a missing binary raises. ``server_args`` carries the +Executes tmux via the CLI binary, one fork per command, mirroring today's +:class:`libtmux.common.tmux_cmd` output handling: ``backslashreplace`` decoding +and trailing-blank stripping. A tmux-side failure is returned as data (nonzero +``returncode`` plus ``stderr``); only a missing binary raises. ``server_args`` +carries the connection flags (``-L``/``-S``/``-f``/``-2``) so the engine can target a specific tmux server. """ @@ -82,9 +82,6 @@ def run(self, request: CommandRequest) -> CommandResult: stdout_lines.pop() stderr_lines = [line for line in stderr.split("\n") if line] - if "has-session" in cmd and stderr_lines and not stdout_lines: - stdout_lines = [stderr_lines[0]] - return CommandResult( cmd=tuple(cmd), stdout=tuple(stdout_lines), diff --git a/src/libtmux/experimental/ops/_ops/has_session.py b/src/libtmux/experimental/ops/_ops/has_session.py index 0c236763e..2f1fa9c8b 100644 --- a/src/libtmux/experimental/ops/_ops/has_session.py +++ b/src/libtmux/experimental/ops/_ops/has_session.py @@ -53,7 +53,14 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> HasSessionResult: - """Map the exit code to existence; the query itself always completes.""" + """Map the exit code to existence; the query itself always completes. + + ``has-session`` writes its "can't find session" message to stderr; surface + it in stdout here (rather than in each engine) so the result is consistent + across engines. + """ + if stderr and not stdout: + stdout = (stderr[0],) return HasSessionResult( operation=self, argv=argv, diff --git a/tests/experimental/ops/test_read_breadth.py b/tests/experimental/ops/test_read_breadth.py index 486971b81..463171764 100644 --- a/tests/experimental/ops/test_read_breadth.py +++ b/tests/experimental/ops/test_read_breadth.py @@ -171,6 +171,17 @@ def test_read_result_round_trip( assert result_from_dict(result_to_dict(result)) == result +def test_has_session_folds_stderr_to_stdout() -> None: + """A missing session's stderr is surfaced in stdout (engine-agnostic).""" + result = HasSession(target=SessionId("$9")).build_result( + returncode=1, + stderr=("can't find session: $9",), + ) + assert result.exists is False + assert result.stdout == ("can't find session: $9",) + assert result.stderr == ("can't find session: $9",) + + def test_has_session_live(session: Session) -> None: """has-session answers True for the fixture session, False for a fake one.""" from libtmux.experimental.engines import SubprocessEngine From 01e16d51f5820bee546720409f50c6b83ef90341 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:01:21 -0500 Subject: [PATCH 050/223] Engines(docs): Note ConcreteEngine query-simulation limits why: ConcreteEngine is stateless, so has-session (and other existence queries) always report success -- HasSession.exists is always True through it. That surprise should be documented. what: - Add a Notes section to ConcreteEngine documenting the stateless simulation and that queries like has-session need a live engine --- src/libtmux/experimental/engines/concrete.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index 841c4f8c8..e8f84e3de 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -61,6 +61,13 @@ class ConcreteEngine: capture_lines : Sequence[str] Lines that ``capture-pane`` returns. + Notes + ----- + The simulation is stateless -- it fabricates ids for ``-P -F`` creators and + returns canned ``capture-pane`` lines, but has no notion of which objects + exist, so queries like ``has-session`` always succeed (``HasSession.exists`` + is always ``True``). Use a live engine for those. + Examples -------- >>> from libtmux.experimental.ops import SplitWindow, CapturePane, run From 0e168869d6309af191b1a240f1d0aaa2a9453614 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:07:55 -0500 Subject: [PATCH 051/223] Engines(fix): Avoid imsg UnboundLocalError on socket() failure why: imsg _connect created the socket inside the try whose except calls sock.close(); if socket() itself failed (e.g. fd exhaustion), sock was unbound and the handler raised UnboundLocalError, masking the real OSError. what: - Create the socket before the try so the except only runs once sock exists - Add test_imsg_connect_socket_failure_raises_oserror (monkeypatched socket.socket) --- src/libtmux/experimental/engines/imsg/base.py | 4 +++- tests/experimental/engines/test_imsg.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index d447bc40b..d998cd872 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -397,8 +397,10 @@ def _connect( *, socket_path: str, ) -> socket.socket: + # Create the socket outside the try so the except never references an + # unbound `sock` if socket() itself fails (e.g. fd exhaustion). + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect(socket_path) except OSError as error: sock.close() diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py index 0715c2025..2ee662846 100644 --- a/tests/experimental/engines/test_imsg.py +++ b/tests/experimental/engines/test_imsg.py @@ -30,6 +30,24 @@ ) +@needs_af_unix +def test_imsg_connect_socket_failure_raises_oserror( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A socket() failure surfaces as OSError, not UnboundLocalError.""" + import errno + + def _boom(*_args: object, **_kwargs: object) -> object: + raise OSError(errno.EMFILE, "too many open files") + + monkeypatch.setattr( + "libtmux.experimental.engines.imsg.base.socket.socket", + _boom, + ) + with pytest.raises(OSError, match="too many open files"): + ImsgEngine()._connect(socket_path="/nonexistent") + + def test_imsg_registered() -> None: """The imsg engine is registered and constructible by name.""" assert "imsg" in available_engines() From 7843ec808176029988f4b6da108aae347e78a4fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:11:58 -0500 Subject: [PATCH 052/223] Engines(fix): Return imsg exit result on clean close after MSG_EXIT why: If the tmux server closed the socket right after MSG_EXIT (before MSG_EXITED), recv_frame raised ImsgProtocolError, which run() did not catch -- so a normal command exit became an exception, diverging from the subprocess engine. what: - Catch ImsgProtocolError around recv_frame; once seen_exit is set, treat a clean close as the end and return the computed exit result --- src/libtmux/experimental/engines/imsg/base.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index d998cd872..241d97b4e 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -506,7 +506,14 @@ def _run_socket_command( transport.send_frame(codec, command_frame) while True: - frame = transport.recv_frame(codec) + try: + frame = transport.recv_frame(codec) + except ImsgProtocolError: + # The server may close the socket right after MSG_EXIT, + # before MSG_EXITED; the exit result is already computed. + if seen_exit: + break + raise msg_type = frame.header.msg_type peer = frame.header.peer_id pid = frame.header.pid From 4efa7d5881d19a4edd6d7793a3281aed81ff75fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:18:25 -0500 Subject: [PATCH 053/223] Engines(fix): Close imsg dup'd fds if the identify send never happens why: _run_socket_command duplicated stdin/stdout fds for SCM_RIGHTS transfer, but if building the identify frames or opening the transport raised before send_frames ran, those descriptors leaked (send_frames only closes the fds once it owns the frames). what: - Close the dup'd fds if codec.identify_messages or the transport constructor raises; once send_frames runs it owns/closes them --- src/libtmux/experimental/engines/imsg/base.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 241d97b4e..61e03f221 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -471,17 +471,24 @@ def _run_socket_command( for key in self._probe_env_keys if key in os.environ } - identify_frames = codec.identify_messages( - cwd=str(pathlib.Path.cwd()), - term=os.environ.get("TERM", "unknown") or "unknown", - tty_name="", - client_pid=os.getpid(), - environ=environ_to_send, - flags=self._client_flags(), - features=0, - stdin_fd=stdin_fd, - stdout_fd=stdout_fd, - ) + # The dup'd fds are owned by send_frames once the identify burst is sent; + # close them if building the frames or opening the transport fails first. + try: + identify_frames = codec.identify_messages( + cwd=str(pathlib.Path.cwd()), + term=os.environ.get("TERM", "unknown") or "unknown", + tty_name="", + client_pid=os.getpid(), + environ=environ_to_send, + flags=self._client_flags(), + features=0, + stdin_fd=stdin_fd, + stdout_fd=stdout_fd, + ) + except BaseException: + _close_fd(stdin_fd) + _close_fd(stdout_fd) + raise logger.debug( "sending imsg identify burst", extra={ @@ -499,7 +506,12 @@ def _run_socket_command( exit_message: str | None = None seen_exit = False - transport = _SelectorSocketTransport(sock) + try: + transport = _SelectorSocketTransport(sock) + except BaseException: + _close_fd(stdin_fd) + _close_fd(stdout_fd) + raise try: transport.send_frames(codec, identify_frames) command_frame = codec.command_message(command_argv, peer_id=peer_id) From a8511b85c39e54d502949d27dfea019a9890c670 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 20:40:18 -0500 Subject: [PATCH 054/223] Imsg(fix): Send identify LONGFLAGS frame once why: The v8 identify burst sent MSG_IDENTIFY_LONGFLAGS twice -- a byte-identical copy-paste in the initial codec. A real tmux client sends it once; the duplicate is harmless (the server sets the flags idempotently) but is redundant wire traffic. what: - Drop the duplicate MSG_IDENTIFY_LONGFLAGS frame in ProtocolV8Codec.identify_messages - Add a parametrized regression test asserting each identify frame type is emitted the expected number of times (LONGFLAGS once) --- src/libtmux/experimental/engines/imsg/v8.py | 5 --- tests/experimental/engines/test_imsg.py | 44 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/engines/imsg/v8.py b/src/libtmux/experimental/engines/imsg/v8.py index 3221da5f5..c1687f3f5 100644 --- a/src/libtmux/experimental/engines/imsg/v8.py +++ b/src/libtmux/experimental/engines/imsg/v8.py @@ -306,11 +306,6 @@ def identify_messages( """Build the identify handshake messages for a tmux client.""" peer_id = int(self.version) messages = [ - self.frame_message( - MessageType.MSG_IDENTIFY_LONGFLAGS, - _UINT64.pack(flags), - peer_id=peer_id, - ), self.frame_message( MessageType.MSG_IDENTIFY_LONGFLAGS, _UINT64.pack(flags), diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py index 2ee662846..c70e39abe 100644 --- a/tests/experimental/engines/test_imsg.py +++ b/tests/experimental/engines/test_imsg.py @@ -19,7 +19,11 @@ available_engines, create_engine, ) -from libtmux.experimental.engines.imsg.v8 import IMSG_HEADER_SIZE, ProtocolV8Codec +from libtmux.experimental.engines.imsg.v8 import ( + IMSG_HEADER_SIZE, + MessageType, + ProtocolV8Codec, +) if t.TYPE_CHECKING: from libtmux.session import Session @@ -78,6 +82,44 @@ def test_v8_command_message_packs_argc_and_argv() -> None: assert frame.payload.endswith(b"#{session_id}\x00") +class IdentifyFrameCase(t.NamedTuple): + """One expected identify-burst frame count.""" + + test_id: str + msg_type: MessageType + expected: int + + +IDENTIFY_FRAME_CASES = ( + IdentifyFrameCase("longflags-once", MessageType.MSG_IDENTIFY_LONGFLAGS, 1), + IdentifyFrameCase("stdin-once", MessageType.MSG_IDENTIFY_STDIN, 1), + IdentifyFrameCase("stdout-once", MessageType.MSG_IDENTIFY_STDOUT, 1), + IdentifyFrameCase("done-once", MessageType.MSG_IDENTIFY_DONE, 1), + IdentifyFrameCase("environ-one-per-var", MessageType.MSG_IDENTIFY_ENVIRON, 2), +) + + +@pytest.mark.parametrize( + "case", + IDENTIFY_FRAME_CASES, + ids=[case.test_id for case in IDENTIFY_FRAME_CASES], +) +def test_identify_burst_frame_counts(case: IdentifyFrameCase) -> None: + """The identify burst emits each message type the expected number of times. + + A real tmux client sends ``MSG_IDENTIFY_LONGFLAGS`` exactly once. + """ + frames = ProtocolV8Codec().identify_messages( + cwd="/tmp", + term="xterm", + tty_name="", + client_pid=123, + environ={"A": "1", "B": "2"}, + ) + count = sum(1 for frame in frames if frame.header.msg_type == int(case.msg_type)) + assert count == case.expected + + def _socket_prefix(server: t.Any) -> tuple[str, ...]: """Build the -L/-S flag that targets the test server's socket.""" if server.socket_name: From 99a64fdbb93480c59c4a2af8737db5d5771aab1e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 17:12:41 -0500 Subject: [PATCH 055/223] Engines(test): Widen async control-mode coverage why: The async control-mode tests only dispatched single-command operations on happy paths, leaving the reader's multi-block correlation, batch pipelining, lifecycle short-circuits, and the error-as-data policy uncovered. what: - Cover %output and no-% notification parsing - Test run_batch([]) short-circuit and aclose-before-start no-op - Assert for_server threads the live socket into server_args - Pipeline two requests through one run_batch call - Fold a ; chain via FoldingPlanner (exercises expected>1 blocks) - Confirm a rejected command returns a failed result, no raise --- .../contract/test_async_control_engine.py | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/tests/experimental/contract/test_async_control_engine.py b/tests/experimental/contract/test_async_control_engine.py index b6d9ec5ac..bcdc24580 100644 --- a/tests/experimental/contract/test_async_control_engine.py +++ b/tests/experimental/contract/test_async_control_engine.py @@ -13,13 +13,24 @@ from libtmux.experimental.engines import ( AsyncConcreteEngine, AsyncControlModeEngine, + CommandRequest, ControlNotification, ) -from libtmux.experimental.ops import SplitWindow, arun +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + RenameWindow, + SplitWindow, + arun, +) from libtmux.experimental.ops._types import WindowId from libtmux.experimental.ops.results import SplitWindowResult if t.TYPE_CHECKING: + from libtmux.experimental.engines import CommandResult + from libtmux.experimental.ops.plan import PlanResult + from libtmux.experimental.ops.results import AckResult + from libtmux.server import Server from libtmux.session import Session @@ -30,6 +41,21 @@ def test_notification_parse() -> None: assert notif.args == ("@3",) +def test_notification_parse_output_keeps_payload() -> None: + """An ``%output`` line keeps the pane id and the whole payload as args.""" + notif = ControlNotification.parse(b"%output %1 hello world") + assert notif.kind == "output" + assert notif.args == ("%1", "hello", "world") + assert notif.raw == "%output %1 hello world" + + +def test_notification_parse_line_without_percent() -> None: + """A line lacking the ``%`` prefix still parses to a kind and args.""" + notif = ControlNotification.parse(b"window-renamed @1 new") + assert notif.kind == "window-renamed" + assert notif.args == ("@1", "new") + + def test_async_control_split_creates_real_pane(session: Session) -> None: """An async control-mode split returns a typed result; the pane exists.""" server = session.server @@ -96,3 +122,91 @@ async def main() -> ControlNotification: notif = asyncio.run(main()) assert notif.kind assert notif.raw.startswith("%") + + +def test_async_control_empty_batch_short_circuits() -> None: + """``run_batch([])`` returns ``[]`` without ever spawning a tmux process.""" + engine = AsyncControlModeEngine() + assert asyncio.run(engine.run_batch([])) == [] + + +def test_async_control_aclose_without_start_is_safe() -> None: + """Closing an engine that was never started is a no-op, not an error.""" + engine = AsyncControlModeEngine() + asyncio.run(engine.aclose()) + assert engine.dropped_notifications == 0 + + +def test_async_control_for_server_carries_socket(server: Server) -> None: + """``for_server`` threads the live server's socket into the connection flags.""" + engine = AsyncControlModeEngine.for_server(server) + assert any(arg.startswith(("-L", "-S")) for arg in engine.server_args) + assert engine.tmux_bin == server.tmux_bin + + +def test_async_control_run_batch_pipelines_one_call(session: Session) -> None: + """One ``run_batch`` call dispatches several requests, one result each, in order.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + request = CommandRequest.from_args( + *SplitWindow(target=WindowId(window_id)).render() + ) + + async def main() -> list[CommandResult]: + async with AsyncControlModeEngine.for_server(server) as engine: + return await engine.run_batch([request, request]) + + results = asyncio.run(main()) + assert len(results) == 2 + assert all(result.returncode == 0 for result in results) + # Each split captured a distinct new pane id on its own block. + assert results[0].stdout and results[1].stdout + assert results[0].stdout[0] != results[1].stdout[0] + + +def test_async_control_folds_chain_over_one_dispatch(session: Session) -> None: + """A folded ``;`` chain dispatches as one multi-block command; each op completes. + + The other tests dispatch only single-command operations, so the reader's + "wait for ``expected`` blocks" correlation (``command_count`` > 1) is never + exercised. A ``FoldingPlanner`` chain of two renames sends one ``a ; b`` line + that tmux answers with two blocks, proving block accumulation and per-op + attribution over the async connection. + """ + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + plan = LazyPlan() + plan.add_chain( + RenameWindow(target=WindowId(window_id), name="first") + >> RenameWindow(target=WindowId(window_id), name="folded"), + ) + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await plan.aexecute(engine, planner=FoldingPlanner()) + + outcome = asyncio.run(main()) + assert outcome.ok + assert [result.status for result in outcome.results] == ["complete", "complete"] + # The last rename in the folded line won, proving both sub-commands ran. + renamed = server.windows.get(window_id=window_id) + assert renamed is not None + assert renamed.window_name == "folded" + + +def test_async_control_failure_is_data_not_raised(session: Session) -> None: + """A tmux-rejected command yields a failed result; the engine does not raise.""" + server = session.server + + async def main() -> AckResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun( + RenameWindow(target=WindowId("@999999"), name="nope"), + engine, + ) + + result = asyncio.run(main()) + assert result.ok is False + assert result.returncode != 0 From 6fbb320e1208ac9fe33fc6506924b3b7fbd296eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 17:13:10 -0500 Subject: [PATCH 056/223] Ops(feat[send_keys]): Add suppress_history flag why: Declarative workspace builders (tmuxp-style) need injected setup commands kept out of shell history. tmux has no native flag; the convention is a leading space honored by HISTCONTROL=ignorespace. what: - Add suppress_history field (default False, opt-in, no behavior change) - Prepend a space to the keys when set and not literal - Document the convention and add a render doctest --- src/libtmux/experimental/ops/_ops/send_keys.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py index 3f4cd4c54..4a2fe5b48 100644 --- a/src/libtmux/experimental/ops/_ops/send_keys.py +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -25,6 +25,10 @@ class SendKeys(Operation[AckResult]): pressing Return; send the keys and Enter as two operations instead. literal : bool Send keys literally without tmux key-name lookup (``-l``). + suppress_history : bool + Prepend a single space to the command so an ``ignorespace``-configured + shell (``HISTCONTROL=ignorespace``) keeps it out of history -- the same + trick tmuxp uses. No-op when *literal* is set. Examples -------- @@ -33,6 +37,8 @@ class SendKeys(Operation[AckResult]): ('send-keys', '-t', '%1', 'echo hi', 'Enter') >>> SendKeys(target=PaneId("%1"), keys="q", literal=True).render() ('send-keys', '-t', '%1', '-l', 'q') + >>> SendKeys(target=PaneId("%1"), keys="vim", suppress_history=True).render() + ('send-keys', '-t', '%1', ' vim') """ kind = "send_keys" @@ -45,6 +51,7 @@ class SendKeys(Operation[AckResult]): keys: str enter: bool = False literal: bool = False + suppress_history: bool = False def __post_init__(self) -> None: """Reject literal+enter (fail closed): tmux ``-l`` types "Enter".""" @@ -60,7 +67,10 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out: list[str] = [] if self.literal: out.append("-l") - out.append(self.keys) + keys = self.keys + if self.suppress_history and not self.literal: + keys = f" {keys}" + out.append(keys) if self.enter: out.append("Enter") return tuple(out) From 2001eeb435dbaf03e8a5675122e3cece7f562ec9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 17:20:45 -0500 Subject: [PATCH 057/223] Ops(feat): Capture implicit child ids on create why: A declarative WorkspaceBuilder must target the first pane of a created window (e.g. to focus it) without the caller handling ids. The implicit first pane had no captured id, so it could only be addressed as the active pane -- which moves once the window is split. what: - NewSession.capture_panes / NewWindow.capture_pane (opt-in): emit a multi-id -F so the result also carries first_window_id/first_pane_id - CreateResult gains first_window_id/first_pane_id + created_subids - SlotRef.part ("self"/"window"/"pane") + .window/.pane sub-refs; the plan binds created_subids so a sub-ref resolves to its captured id - ConcreteEngine fabricates one id per #{*_id} token (single-token formats unchanged, preserving existing fabricated-id sequences) --- src/libtmux/experimental/engines/concrete.py | 24 ++++++++++---- .../experimental/ops/_ops/new_session.py | 31 ++++++++++++++++--- .../experimental/ops/_ops/new_window.py | 23 +++++++++++--- src/libtmux/experimental/ops/_types.py | 22 +++++++++++-- src/libtmux/experimental/ops/plan.py | 30 ++++++++++++------ src/libtmux/experimental/ops/results.py | 25 +++++++++++++++ 6 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index e8f84e3de..50774e431 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -22,12 +22,24 @@ def _fabricate(fmt: str, counters: dict[str, int]) -> str: - """Return the next fabricated id for a ``#{..._id}`` capture format.""" - for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): - if key in fmt: - counters[key] += 1 - return f"{sigil}{counters[key]}" - return "?" + """Fabricate one id per ``#{..._id}`` token in *fmt*, in their order. + + A single-token format (e.g. ``#{pane_id}``) yields one id, preserving the + historical behaviour; a multi-token capture (e.g. ``new-session -F + '#{session_id} #{window_id} #{pane_id}'``) yields a space-joined id per token. + """ + found: list[tuple[int, str, str]] = [] + for key, sigil in (("session_id", "$"), ("window_id", "@"), ("pane_id", "%")): + index = fmt.find(f"#{{{key}}}") + if index != -1: + found.append((index, key, sigil)) + if not found: + return "?" + parts: list[str] = [] + for _index, key, sigil in sorted(found): + counters[key] += 1 + parts.append(f"{sigil}{counters[key]}") + return " ".join(parts) def _simulate( diff --git a/src/libtmux/experimental/ops/_ops/new_session.py b/src/libtmux/experimental/ops/_ops/new_session.py index 74925d91b..b92759c24 100644 --- a/src/libtmux/experimental/ops/_ops/new_session.py +++ b/src/libtmux/experimental/ops/_ops/new_session.py @@ -21,12 +21,27 @@ class NewSession(Operation[CreateResult]): """Create a detached session; capture the new session's id. + Parameters + ---------- + capture_panes : bool + Also capture the new session's first window id and first pane id (into + :attr:`~.results.CreateResult.first_window_id` / + :attr:`~.results.CreateResult.first_pane_id`), so a plan can target them + via ``slot.window`` / ``slot.pane``. + Examples -------- >>> NewSession(session_name="work").render() ('new-session', '-d', '-s', 'work', '-P', '-F', '#{session_id}') + >>> NewSession(session_name="work", capture_panes=True).render()[-1] + '#{session_id} #{window_id} #{pane_id}' >>> NewSession().build_result(returncode=0, stdout=("$2",)).new_id '$2' + >>> r = NewSession(capture_panes=True).build_result( + ... returncode=0, stdout=("$2 @3 %4",) + ... ) + >>> (r.new_id, r.first_window_id, r.first_pane_id) + ('$2', '@3', '%4') """ kind = "new_session" @@ -44,6 +59,7 @@ class NewSession(Operation[CreateResult]): width: int | None = None height: int | None = None capture: bool = True + capture_panes: bool = False def args(self, *, version: str | None = None) -> tuple[str, ...]: """Render ``new-session`` flags (always detached for headless use).""" @@ -59,7 +75,12 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: if self.height is not None: out.extend(("-y", str(self.height))) if self.capture: - out.extend(("-P", "-F", "#{session_id}")) + fmt = ( + "#{session_id} #{window_id} #{pane_id}" + if self.capture_panes + else "#{session_id}" + ) + out.extend(("-P", "-F", fmt)) return tuple(out) def _make_result( @@ -71,8 +92,8 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> CreateResult: - """Parse the captured new-session id.""" - new_id = stdout[0].strip() if status == "complete" and stdout else None + """Parse the captured session id (and first window/pane id if captured).""" + ids = stdout[0].split() if status == "complete" and stdout else [] return CreateResult( operation=self, argv=argv, @@ -80,5 +101,7 @@ def _make_result( returncode=returncode, stdout=stdout, stderr=stderr, - new_id=new_id, + new_id=ids[0] if ids else None, + first_window_id=ids[1] if len(ids) > 1 else None, + first_pane_id=ids[2] if len(ids) > 2 else None, ) diff --git a/src/libtmux/experimental/ops/_ops/new_window.py b/src/libtmux/experimental/ops/_ops/new_window.py index 73091a437..d6820c787 100644 --- a/src/libtmux/experimental/ops/_ops/new_window.py +++ b/src/libtmux/experimental/ops/_ops/new_window.py @@ -23,15 +23,27 @@ class NewWindow(Operation[CreateResult]): ``target`` is the session the window is created in. + Parameters + ---------- + capture_pane : bool + Also capture the new window's first pane id (into + :attr:`~.results.CreateResult.first_pane_id`), so a plan can target it + via ``slot.pane``. + Examples -------- >>> from libtmux.experimental.ops._types import SessionId >>> NewWindow(target=SessionId("$0"), name="build").render() ('new-window', '-t', '$0', '-d', '-n', 'build', '-P', '-F', '#{window_id}') + >>> NewWindow(target=SessionId("$0"), capture_pane=True).render()[-1] + '#{window_id} #{pane_id}' >>> NewWindow(target=SessionId("$0")).build_result( ... returncode=0, stdout=("@5",) ... ).new_id '@5' + >>> r = NewWindow(capture_pane=True).build_result(returncode=0, stdout=("@5 %6",)) + >>> (r.new_id, r.first_pane_id) + ('@5', '%6') """ kind = "new_window" @@ -48,6 +60,7 @@ class NewWindow(Operation[CreateResult]): environment: Mapping[str, str] | None = None detach: bool = True capture: bool = True + capture_pane: bool = False window_shell: str | None = None def args(self, *, version: str | None = None) -> tuple[str, ...]: @@ -62,7 +75,8 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: if self.environment and self.flag_available("environment", version): out.extend(f"-e{key}={value}" for key, value in self.environment.items()) if self.capture: - out.extend(("-P", "-F", "#{window_id}")) + fmt = "#{window_id} #{pane_id}" if self.capture_pane else "#{window_id}" + out.extend(("-P", "-F", fmt)) if self.window_shell is not None: out.append(self.window_shell) return tuple(out) @@ -76,8 +90,8 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> CreateResult: - """Parse the captured new-window id.""" - new_id = stdout[0].strip() if status == "complete" and stdout else None + """Parse the captured window id (and first pane id if captured).""" + ids = stdout[0].split() if status == "complete" and stdout else [] return CreateResult( operation=self, argv=argv, @@ -85,5 +99,6 @@ def _make_result( returncode=returncode, stdout=stdout, stderr=stderr, - new_id=new_id, + new_id=ids[0] if ids else None, + first_pane_id=ids[1] if len(ids) > 1 else None, ) diff --git a/src/libtmux/experimental/ops/_types.py b/src/libtmux/experimental/ops/_types.py index 897052591..d0220bbca 100644 --- a/src/libtmux/experimental/ops/_types.py +++ b/src/libtmux/experimental/ops/_types.py @@ -277,16 +277,34 @@ class SlotRef: lets a command needing a qualified target -- e.g. ``new-window -t $N:`` -- reuse a plain captured ``$N``. + ``part`` selects which captured id to resolve: the slot's own created object + (``"self"``, the default), or an *implicit child* the creator captured -- a + new session/window's first window (``"window"``) or first pane (``"pane"``). + Use the :attr:`window` / :attr:`pane` convenience properties. + Examples -------- >>> SlotRef(0) - SlotRef(slot=0, suffix='') + SlotRef(slot=0, suffix='', part='self') >>> SlotRef(0, ":") - SlotRef(slot=0, suffix=':') + SlotRef(slot=0, suffix=':', part='self') + >>> SlotRef(0).pane + SlotRef(slot=0, suffix='', part='pane') """ slot: int suffix: str = "" + part: t.Literal["self", "window", "pane"] = "self" + + @property + def window(self) -> SlotRef: + """A sub-ref to the first window the slot's creator captured.""" + return SlotRef(self.slot, self.suffix, "window") + + @property + def pane(self) -> SlotRef: + """A sub-ref to the first pane the slot's creator captured.""" + return SlotRef(self.slot, self.suffix, "pane") def render(self) -> str: """Raise -- an unresolved deferred ref cannot be rendered.""" diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index f8fb2723e..e530a9867 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -78,14 +78,20 @@ def _target_from_id(value: str) -> Target: return Special(value) -def _resolve_slot(ref: SlotRef, bindings: dict[int, str]) -> Target: +def _resolve_slot( + ref: SlotRef, + bindings: dict[int | tuple[int, str], str], +) -> Target: """Map a :class:`SlotRef` to the captured concrete target it points at.""" + key: int | tuple[int, str] = ( + ref.slot if ref.part == "self" else (ref.slot, ref.part) + ) try: - concrete = bindings[ref.slot] + ref.suffix + concrete = bindings[key] + ref.suffix except KeyError as error: msg = ( - f"slot {ref.slot} has no captured id yet; a plan step can only " - f"reference an earlier step that creates an object" + f"slot {ref.slot} (part {ref.part!r}) has no captured id yet; a plan " + f"step can only reference an earlier step that creates that object" ) raise OperationError(msg) from error return _target_from_id(concrete) @@ -93,7 +99,7 @@ def _resolve_slot(ref: SlotRef, bindings: dict[int, str]) -> Target: def _resolve( operation: Operation[t.Any], - bindings: dict[int, str], + bindings: dict[int | tuple[int, str], str], ) -> Operation[t.Any]: """Substitute any :class:`SlotRef` ``target``/``src_target`` with its id.""" changes: dict[str, Target] = {} @@ -108,7 +114,7 @@ def _resolve( def _resolve_src( operation: Operation[t.Any], - bindings: dict[int, str], + bindings: dict[int | tuple[int, str], str], ) -> Operation[t.Any]: """Resolve only a :class:`SlotRef` ``src_target``. @@ -133,12 +139,14 @@ class PlanResult: ---------- results : tuple[Result, ...] One result per recorded operation, in order. - bindings : dict[int, str] - Maps a creating step's index to the concrete id it produced. + bindings : dict[int | tuple[int, str], str] + Maps a creating step's index to the concrete id it produced; a + ``(index, part)`` key holds an implicit child's id (e.g. a new window's + first pane), bound when the creator opts into capturing it. """ results: tuple[Result, ...] - bindings: dict[int, str] = field(default_factory=dict) + bindings: dict[int | tuple[int, str], str] = field(default_factory=dict) @property def ok(self) -> bool: @@ -226,7 +234,7 @@ def _drive( The sync and async drivers differ only in ``run`` vs ``await arun`` and ``engine.run`` vs ``await engine.run``. """ - bindings: dict[int, str] = {} + bindings: dict[int | tuple[int, str], str] = {} results: dict[int, Result] = {} for step in planner.plan(self._operations): if step.marked: @@ -254,6 +262,8 @@ def _drive( results[index] = result if result.created_id is not None: bindings[index] = result.created_id + for sub_part, sub_id in result.created_subids.items(): + bindings[index, sub_part] = sub_id else: group = [_resolve(self._operations[i], bindings) for i in step.indices] merged = yield _Chain(render_chain(group, version)) diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 1e0dfad70..ae34b9463 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -141,6 +141,16 @@ def created_id(self) -> str | None: """ return None + @property + def created_subids(self) -> Mapping[str, str]: + """Ids of implicit children this op created (e.g. a window's first pane). + + Keyed by part (``"window"`` / ``"pane"``); empty by default. A lazy plan + binds these so a :class:`~._types.SlotRef` sub-reference (``slot.pane`` / + ``slot.window``) can target an object created as a side effect. + """ + return {} + def raise_for_status(self) -> Self: """Raise :class:`~.exc.TmuxCommandError` if the result is not OK. @@ -215,15 +225,30 @@ class CreateResult(Result): Shared by ``new-window`` / ``new-session`` (and other ``-P -F``-capturing creators); :attr:`new_id` holds the created object's id (``@N``/``$N``). + When the creator opts into capturing its implicit children (a session's first + window/pane, a window's first pane), those ids land in + :attr:`first_window_id` / :attr:`first_pane_id` and in :attr:`created_subids`. """ new_id: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None @property def created_id(self) -> str | None: """The created object's id.""" return self.new_id + @property + def created_subids(self) -> Mapping[str, str]: + """The captured implicit children, keyed by ``"window"`` / ``"pane"``.""" + out: dict[str, str] = {} + if self.first_window_id is not None: + out["window"] = self.first_window_id + if self.first_pane_id is not None: + out["pane"] = self.first_pane_id + return out + @dataclass(frozen=True) class CapturePaneResult(Result): From 4fbe92efd441ada30db8236d2b10d22f838e5f3a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 18:12:46 -0500 Subject: [PATCH 058/223] Workspace(feat): Declarative WorkspaceBuilder on the typed-ops Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxp-style workspace creation needs a structural, declarative object language (à la SQLAlchemy Declarative on Core) -- declare the shape of a session/windows/panes and let a compiler lower it to Core operations, engine- and sync/async-neutral, instead of hand-driving ops. what: - New experimental.workspace package: analyzer (tmuxp YAML/dict -> IR), ir (Workspace/Window/Pane specs), compiler (spec -> Core LazyPlan, wiring first-pane sub-refs so the user never handles an id), runner (build/abuild over any engine + host steps + idempotent replace), confirm (live structure diff) - Workspace.compile()/build()/abuild(); on_exists error|replace|reuse - Robust QA: offline (op order, plan serialize round-trip, 3-way planner equivalence) + live (rich 3-window build over subprocess and async-control: names/order, pane counts, focus, options, env, cwd, commands) --- .../experimental/workspace/__init__.py | 51 +++ .../experimental/workspace/analyzer.py | 120 +++++++ .../experimental/workspace/compiler.py | 236 +++++++++++++ src/libtmux/experimental/workspace/confirm.py | 88 +++++ src/libtmux/experimental/workspace/ir.py | 185 +++++++++++ src/libtmux/experimental/workspace/runner.py | 146 +++++++++ ..._async_control_engine_workspace_builder.py | 309 ++++++++++++++++++ 7 files changed, 1135 insertions(+) create mode 100644 src/libtmux/experimental/workspace/__init__.py create mode 100644 src/libtmux/experimental/workspace/analyzer.py create mode 100644 src/libtmux/experimental/workspace/compiler.py create mode 100644 src/libtmux/experimental/workspace/confirm.py create mode 100644 src/libtmux/experimental/workspace/ir.py create mode 100644 src/libtmux/experimental/workspace/runner.py create mode 100644 tests/experimental/contract/test_async_control_engine_workspace_builder.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py new file mode 100644 index 000000000..9b1f34bfe --- /dev/null +++ b/src/libtmux/experimental/workspace/__init__.py @@ -0,0 +1,51 @@ +"""Declarative WorkspaceBuilder: a structural object language over the Core ops. + +The *Declarative* tier (à la SQLAlchemy Declarative on Core). Declare a workspace +shape with :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane`; +:func:`~.analyzer.analyze` builds that tree from a tmuxp-style YAML/dict; the +compiler lowers it to a Core :class:`~libtmux.experimental.ops.plan.LazyPlan`; the +runner executes it over any engine, sync or async; :func:`~.confirm.confirm` +verifies the live result. + +Everything here is experimental and outside the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> ws = analyze({ +... "session_name": "dev", +... "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], +... }) +>>> ws.build(ConcreteEngine(), preflight=False).ok +True +""" + +from __future__ import annotations + +from libtmux.experimental.workspace.analyzer import analyze +from libtmux.experimental.workspace.compiler import ( + Compiled, + HostStep, + WorkspaceCompileError, + compile_full, + compile_workspace, +) +from libtmux.experimental.workspace.confirm import ConfirmReport, confirm +from libtmux.experimental.workspace.ir import Pane, Window, Workspace +from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace + +__all__ = ( + "Compiled", + "ConfirmReport", + "HostStep", + "Pane", + "Window", + "Workspace", + "WorkspaceCompileError", + "abuild_workspace", + "analyze", + "build_workspace", + "compile_full", + "compile_workspace", + "confirm", +) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py new file mode 100644 index 000000000..55118204f --- /dev/null +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -0,0 +1,120 @@ +"""Analyze a tmuxp-style YAML/dict workspace into the declarative IR. + +A small subset of tmuxp's ``loader.expand``/``trickle``: it normalizes shorthand +(a bare-string pane, a string/list ``shell_command``, a ``cmd``-dict list) into the +canonical :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane` tree. +Pure -- no tmux. + +Examples +-------- +>>> ws = analyze({ +... "session_name": "dev", +... "start_directory": "~/work", +... "windows": [ +... {"window_name": "editor", "layout": "main-vertical", +... "panes": ["vim", {"shell_command": ["cd src", "pytest -q"]}]}, +... {"window_name": "logs", "panes": ["tail -f app.log"]}, +... ], +... }) +>>> ws.name +'dev' +>>> [w.name for w in ws.windows] +['editor', 'logs'] +>>> ws.windows[0].panes[0].commands +('vim',) +>>> ws.windows[0].panes[1].commands +('cd src', 'pytest -q') +""" + +from __future__ import annotations + +import collections.abc +import typing as t + +from libtmux.experimental.workspace.ir import Pane, Window, Workspace + + +def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: + """Normalize a tmuxp-style config (dict or YAML string) into a Workspace.""" + data = _load(raw) + windows = [_window(w) for w in data.get("windows", []) or []] + return Workspace( + name=data["session_name"], + dimensions=_dimensions(data.get("dimensions")), + start_directory=data.get("start_directory"), + environment=dict(data.get("environment", {}) or {}), + options=dict(data.get("options", {}) or {}), + windows=windows, + before_script=data.get("before_script"), + on_exists=data.get("on_exists", "error"), + ) + + +def _load( + raw: collections.abc.Mapping[str, t.Any] | str, +) -> collections.abc.Mapping[str, t.Any]: + """Return a mapping from a dict or a YAML string.""" + if isinstance(raw, str): + import yaml # type: ignore[import-untyped] + + loaded = yaml.safe_load(raw) + if not isinstance(loaded, collections.abc.Mapping): + msg = "workspace YAML must be a mapping" + raise TypeError(msg) + return loaded + return raw + + +def _dimensions(value: t.Any) -> tuple[int, int] | None: + """Coerce a ``[x, y]`` / ``{width, height}`` value to a dimensions tuple.""" + if value is None: + return None + if isinstance(value, collections.abc.Mapping): + return (int(value["width"]), int(value["height"])) + width, height = value + return (int(width), int(height)) + + +def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: + """Normalize one window config.""" + return Window( + name=raw.get("window_name"), + layout=raw.get("layout"), + start_directory=raw.get("start_directory"), + focus=bool(raw.get("focus", False)), + options=dict(raw.get("options", {}) or {}), + panes=[_pane(p) for p in raw.get("panes", []) or []], + ) + + +def _pane(raw: t.Any) -> Pane: + """Normalize one pane config (None / bare string / mapping).""" + if raw is None: + return Pane() + if isinstance(raw, str): + return Pane(run=raw) + if isinstance(raw, collections.abc.Mapping): + return Pane( + run=_shell_commands(raw.get("shell_command")), + focus=bool(raw.get("focus", False)), + start_directory=raw.get("start_directory"), + sleep_before=raw.get("sleep_before"), + sleep_after=raw.get("sleep_after"), + ) + msg = f"unsupported pane config: {raw!r}" + raise TypeError(msg) + + +def _shell_commands(value: t.Any) -> tuple[str, ...]: + """Normalize a ``shell_command`` (None / string / list of str|{cmd}).""" + if value is None: + return () + if isinstance(value, str): + return (value,) + out: list[str] = [] + for item in t.cast("collections.abc.Sequence[t.Any]", value): + if isinstance(item, str): + out.append(item) + elif isinstance(item, collections.abc.Mapping): + out.append(str(item["cmd"])) + return tuple(out) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py new file mode 100644 index 000000000..8dd9e3efc --- /dev/null +++ b/src/libtmux/experimental/workspace/compiler.py @@ -0,0 +1,236 @@ +"""Compile a declarative :class:`~.ir.Workspace` into a Core ``LazyPlan``. + +This is the *unit-of-work* of the Declarative tier: it walks the structural spec +tree and emits Core operations in tmuxp-faithful order (create session -> per +window: create/rename, window options, reuse the first pane, split the rest, send +keys, apply layout, focus panes; then focus the window last), wiring +:class:`~libtmux.experimental.ops._types.SlotRef` forward references so the caller +never handles a tmux id. + +Implicit-object strategy: creators opt into capturing their implicit children's +ids (``NewSession(capture_panes=True)`` -> session/first-window/first-pane; +``NewWindow(capture_pane=True)`` -> window/first-pane), so every window's first +pane has a real captured id reachable as ``slot.pane`` / ``session.pane``. The +session's first window is reused as window 1 (addressed via ``session.window`` / +``session.pane``); windows 2..N are created detached. Because the first pane has a +concrete id, first-pane focus and any-order sends work, and the +``compile() -> LazyPlan`` stays executable by Core (the sub-ids bind in +``_drive``). + +Host-side steps (sleep / before_script) are returned alongside the plan in a +:class:`Compiled` schedule -- they are *not* recorded as operations, keeping the +Core op spine pure. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops import ( + LazyPlan, + NewSession, + NewWindow, + RenameWindow, + SelectLayout, + SelectPane, + SelectWindow, + SendKeys, + SetEnvironment, + SetOption, + SetWindowOption, + SplitWindow, +) +from libtmux.experimental.workspace.ir import Pane + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import SlotRef + from libtmux.experimental.workspace.ir import Window, Workspace + + +class WorkspaceCompileError(ValueError): + """A declared workspace cannot be lowered to Core operations.""" + + +@dataclass(frozen=True) +class HostStep: + """A host-side step interleaved by the runner (not a tmux operation).""" + + kind: t.Literal["sleep", "script"] + seconds: float | None = None + command: str | None = None + cwd: str | None = None + + +@dataclass(frozen=True) +class Compiled: + """A compiled workspace: the Core plan plus its host-step schedule. + + Parameters + ---------- + plan : LazyPlan + The pure Core operations (executable by any engine via ``execute``). + host_after : Mapping[int, tuple[HostStep, ...]] + Host steps to run *after* the operation at the given index. + pre : tuple[HostStep, ...] + Host steps to run before any operation (e.g. ``before_script``). + """ + + plan: LazyPlan + host_after: Mapping[int, tuple[HostStep, ...]] = field(default_factory=dict) + pre: tuple[HostStep, ...] = () + + +def compile_workspace(ws: Workspace, *, version: str | None = None) -> LazyPlan: + """Lower a declarative workspace into a Core ``LazyPlan`` (ops only). + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace(name="dev", windows=[Window("editor", panes=[Pane(run="vim")])]) + >>> [op.kind for op in compile_workspace(ws).operations] + ['new_session', 'rename_window', 'send_keys'] + """ + return compile_full(ws, version=version).plan + + +def _schedule_before( + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + next_index: int, + step: HostStep, +) -> None: + """Schedule *step* to run just before the op that will land at *next_index*.""" + after = next_index - 1 + if after < 0: + pre.append(step) + else: + host_after.setdefault(after, []).append(step) + + +def _emit_window( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + window: Window, + window_ref: SlotRef, + first_pane_ref: SlotRef, +) -> None: + """Emit a window's options, panes, sends, layout, and pane focus. + + *window_ref* addresses the window (rename/options/layout); *first_pane_ref* is + the captured id of the window's first pane. + """ + for key, value in window.options.items(): + plan.add(SetWindowOption(target=window_ref, option=key, value=value)) + + panes = list(window.panes) or [Pane()] + prev: SlotRef = first_pane_ref + focus_targets: list[SlotRef] = [] + for pane_index, pane in enumerate(panes): + if pane_index == 0: + target: SlotRef = first_pane_ref + else: + target = plan.add( + SplitWindow( + target=prev, + start_directory=( + pane.start_directory + or window.start_directory + or ws.start_directory + ), + ), + ) + if pane.commands: + if pane.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=pane.sleep_before), + ) + for command in pane.commands: + plan.add( + SendKeys( + target=target, + keys=command, + enter=True, + suppress_history=pane.suppress_history, + ), + ) + if pane.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=pane.sleep_after), + ) + if pane.focus: + focus_targets.append(target) + prev = target + + if window.layout is not None: + plan.add(SelectLayout(target=window_ref, layout=window.layout)) + for target in focus_targets: + plan.add(SelectPane(target=target)) + + +def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: + """Lower a workspace into a Core plan plus its host-step schedule.""" + if not ws.windows: + msg = f"workspace {ws.name!r} declares no windows" + raise WorkspaceCompileError(msg) + + plan = LazyPlan() + host_after: dict[int, list[HostStep]] = {} + pre: list[HostStep] = [] + if ws.before_script: + pre.append(HostStep("script", command=ws.before_script, cwd=ws.start_directory)) + + width = ws.dimensions[0] if ws.dimensions else None + height = ws.dimensions[1] if ws.dimensions else None + session = plan.add( + NewSession( + session_name=ws.name, + start_directory=ws.start_directory, + width=width, + height=height, + capture_panes=True, + ), + ) + for key, value in ws.environment.items(): + plan.add(SetEnvironment(target=session, name=key, value=value)) + for key, value in ws.options.items(): + plan.add(SetOption(target=session, option=key, value=value)) + + window_refs: list[SlotRef] = [] + for index, window in enumerate(ws.windows): + if index == 0: + # Reuse the session's implicit first window via its captured ids. + window_ref: SlotRef = session.window + first_pane_ref = session.pane + if window.name is not None: + plan.add(RenameWindow(target=window_ref, name=window.name)) + else: + slot = plan.add( + NewWindow( + target=session, + name=window.name, + start_directory=window.start_directory or ws.start_directory, + capture_pane=True, + ), + ) + window_ref = slot + first_pane_ref = slot.pane + window_refs.append(window_ref) + _emit_window(plan, host_after, pre, ws, window, window_ref, first_pane_ref) + + for index, window in enumerate(ws.windows): + if window.focus: + plan.add(SelectWindow(target=window_refs[index])) + + return Compiled( + plan, + {key: tuple(value) for key, value in host_after.items()}, + tuple(pre), + ) diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py new file mode 100644 index 000000000..020f2e992 --- /dev/null +++ b/src/libtmux/experimental/workspace/confirm.py @@ -0,0 +1,88 @@ +"""Confirm a built workspace matches its declarative spec (live introspection). + +Reads the live server through the classic libtmux objects and diffs the observed +session/window/pane structure against the declared :class:`~.ir.Workspace`. Used by +the live test track; the offline (``ConcreteEngine``) track asserts on the +compiled plan instead, since a stateless engine has no structure to read back. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.experimental.workspace.ir import Workspace + from libtmux.server import Server + + +@dataclass +class ConfirmReport: + """The outcome of confirming a built workspace against its spec.""" + + ok: bool + problems: tuple[str, ...] + + +def confirm(ws: Workspace, server: Server, *, timeout: float = 5.0) -> ConfirmReport: + """Diff the live server against the declared workspace; report mismatches.""" + problems: list[str] = [] + sessions = server.sessions.filter(session_name=ws.name) + if not sessions: + return ConfirmReport(ok=False, problems=(f"session {ws.name!r} not found",)) + session = sessions[0] + + windows = list(session.windows) + if len(windows) != len(ws.windows): + problems.append(f"window count {len(windows)} != declared {len(ws.windows)}") + + for spec, live in zip(ws.windows, windows, strict=False): + if spec.name is not None and live.window_name != spec.name: + problems.append( + f"window name {live.window_name!r} != declared {spec.name!r}" + ) + live_panes = list(live.panes) + expected_panes = max(1, len(spec.panes)) + if len(live_panes) != expected_panes: + problems.append( + f"window {spec.name!r} pane count " + f"{len(live_panes)} != declared {expected_panes}", + ) + focused_panes = [i for i, p in enumerate(spec.panes) if p.focus] + if focused_panes and focused_panes[-1] < len(live_panes): + want_idx = focused_panes[-1] + active_pane = live.active_pane + if active_pane is None or ( + active_pane.pane_id != live_panes[want_idx].pane_id + ): + problems.append( + f"window {spec.name!r} active pane != declared focus " + f"(pane index {want_idx})", + ) + + focused = [w for w in ws.windows if w.focus] + if focused and focused[-1].name is not None: + want = focused[-1].name + active_name = session.active_window.window_name + if active_name != want: + problems.append( + f"active window {active_name!r} != declared focus {want!r}", + ) + + if ws.start_directory and windows: + want_cwd = ws.start_directory + session_id = session.session_id + + def _cwd_ok() -> bool: + fresh = server.sessions.filter(session_id=session_id) + if not fresh: + return False + pane = next(iter(fresh[0].windows)).active_pane + return pane is not None and pane.pane_current_path == want_cwd + + if not retry_until(_cwd_ok, timeout, raises=False): + problems.append(f"first pane cwd != declared {want_cwd!r}") + + return ConfirmReport(ok=not problems, problems=tuple(problems)) diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py new file mode 100644 index 000000000..85e34edca --- /dev/null +++ b/src/libtmux/experimental/workspace/ir.py @@ -0,0 +1,185 @@ +"""Declarative workspace specs -- the structural object language. + +The *Declarative* tier (à la SQLAlchemy Declarative on Core): the user declares +the **shape** of a workspace as a tree of :class:`Workspace` / :class:`Window` / +:class:`Pane` values, and the compiler lowers that tree into a Core +:class:`~libtmux.experimental.ops.plan.LazyPlan`. The specs are pure, immutable +data -- no tmux, no engine -- so they round-trip to/from YAML and can be inspected +before anything runs. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane +>>> ws = Workspace( +... name="dev", +... windows=[ +... Window("editor", layout="main-vertical", panes=[ +... Pane(run="vim"), +... Pane(run="pytest -q", focus=True), +... ]), +... Window("logs", panes=[Pane(run="tail -f app.log")]), +... ], +... ) +>>> ws.compile().operations[0].kind +'new_session' +>>> ws.build(ConcreteEngine(), preflight=False).ok +True +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan, PlanResult + + +@dataclass(frozen=True) +class Pane: + """A pane in the declared workspace. + + Parameters + ---------- + run : str or Sequence[str] or None + Command(s) to send after the pane is created (a bare string is one + command). + focus : bool + Select this pane once its window's panes are built. Focusing the *first* + pane of a multi-pane window is rejected at compile time (the implicit + first pane has no captured id after the window is split). + start_directory : str or None + Working directory (inherited from window/session when unset). + suppress_history : bool + Keep sent commands out of shell history (leading-space trick). + sleep_before, sleep_after : float or None + Host-side delays around this pane's commands (orchestration, not tmux). + """ + + run: str | Sequence[str] | None = None + focus: bool = False + start_directory: str | None = None + suppress_history: bool = True + sleep_before: float | None = None + sleep_after: float | None = None + + @property + def commands(self) -> tuple[str, ...]: + """The pane's commands as a tuple (a bare string becomes one command).""" + if self.run is None: + return () + if isinstance(self.run, str): + return (self.run,) + return tuple(self.run) + + +@dataclass(frozen=True) +class Window: + """A window in the declared workspace. + + Parameters + ---------- + name : str or None + Window name. + layout : str or None + A tmux layout applied after the panes exist (e.g. ``main-vertical``). + start_directory : str or None + Working directory for the window's panes. + focus : bool + Select this window at the end of the build. + options : Mapping[str, str] + ``set-window-option`` key/values. + panes : Sequence[Pane] + The window's panes (the first reuses the window's implicit pane). + """ + + name: str | None = None + layout: str | None = None + start_directory: str | None = None + focus: bool = False + options: Mapping[str, str] = field(default_factory=dict) + panes: Sequence[Pane] = () + + +@dataclass(frozen=True) +class Workspace: + """A declared workspace: a session shape that compiles to Core operations. + + Parameters + ---------- + name : str + Session name. + dimensions : tuple[int, int] or None + ``(width, height)`` for the session (``-x``/``-y``). + start_directory : str or None + Working directory for the session. + environment : Mapping[str, str] + ``set-environment`` key/values. + options : Mapping[str, str] + ``set-option`` (session) key/values. + windows : Sequence[Window] + The session's windows. + before_script : str or None + A host shell command run once before building (orchestration). + on_exists : {"error", "replace", "reuse"} + What to do if a session of this name already exists. + """ + + name: str + dimensions: tuple[int, int] | None = None + start_directory: str | None = None + environment: Mapping[str, str] = field(default_factory=dict) + options: Mapping[str, str] = field(default_factory=dict) + windows: Sequence[Window] = () + before_script: str | None = None + on_exists: t.Literal["error", "replace", "reuse"] = "error" + + def compile(self, *, version: str | None = None) -> LazyPlan: + """Lower this declared workspace into a Core ``LazyPlan`` (ops only). + + The returned plan is the escape hatch to the Core tier: inspect it, + serialize it, or execute it directly with any engine. Host steps + (sleep/before_script) and idempotent replace are applied by + :meth:`build`/:meth:`abuild`, not recorded in the plan. + """ + from libtmux.experimental.workspace.compiler import compile_workspace + + return compile_workspace(self, version=version) + + def build( + self, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + ) -> PlanResult: + """Compile and execute this workspace synchronously over *engine*. + + Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check + (e.g. against the stateless ``ConcreteEngine``, which has no real + sessions to detect). + """ + from libtmux.experimental.workspace.runner import build_workspace + + return build_workspace(self, engine, version=version, preflight=preflight) + + async def abuild( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + ) -> PlanResult: + """Compile and execute this workspace asynchronously over *engine*.""" + from libtmux.experimental.workspace.runner import abuild_workspace + + return await abuild_workspace( + self, + engine, + version=version, + preflight=preflight, + ) diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py new file mode 100644 index 000000000..e99e4aac3 --- /dev/null +++ b/src/libtmux/experimental/workspace/runner.py @@ -0,0 +1,146 @@ +"""Execute a compiled workspace over any engine, sync or async. + +The runner is the Declarative tier's *bound* layer. It keeps the Core operation +spine pure: it drives the compiled plan one operation at a time (reusing Core's +:func:`~libtmux.experimental.ops.plan._resolve` forward-ref resolution) and +interleaves host-side steps (sleep / before_script) *between* operations rather +than weaving them into Core's ``_drive`` generator. Idempotent replace is handled +*around* the build via a ``has-session`` pre-check. + +The same compiled plan runs identically through any engine and through either the +sync (:func:`build_workspace`) or async (:func:`abuild_workspace`) driver -- the +only difference is ``run`` vs ``await arun`` and the host-step executor. + +``preflight=False`` skips the ``on_exists`` ``has-session`` check; use it offline +against the stateless ``ConcreteEngine`` (whose ``has-session`` is always true). +""" + +from __future__ import annotations + +import asyncio +import subprocess +import time +import typing as t + +from libtmux.experimental.ops import HasSession, KillSession, arun, run +from libtmux.experimental.ops._types import NameRef +from libtmux.experimental.ops.plan import PlanResult, _resolve +from libtmux.experimental.workspace.compiler import compile_full + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.results import Result + from libtmux.experimental.workspace.compiler import HostStep + from libtmux.experimental.workspace.ir import Workspace + + +def _run_host_sync(step: HostStep) -> None: + """Execute one host step synchronously.""" + if step.kind == "sleep" and step.seconds is not None: + time.sleep(step.seconds) + elif step.kind == "script" and step.command is not None: + subprocess.run(step.command, shell=True, cwd=step.cwd, check=False) + + +async def _run_host_async(step: HostStep) -> None: + """Execute one host step asynchronously.""" + if step.kind == "sleep" and step.seconds is not None: + await asyncio.sleep(step.seconds) + elif step.kind == "script" and step.command is not None: + proc = await asyncio.create_subprocess_shell(step.command, cwd=step.cwd) + await proc.wait() + + +def _preflight_sync(ws: Workspace, engine: TmuxEngine, version: str | None) -> bool: + """Apply the ``on_exists`` policy; return ``True`` if the build should skip.""" + exists = run(HasSession(target=NameRef(ws.name)), engine, version=version).exists + if not exists: + return False + if ws.on_exists == "replace": + run(KillSession(target=NameRef(ws.name)), engine, version=version) + return False + if ws.on_exists == "reuse": + return True + msg = f"session {ws.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +async def _preflight_async( + ws: Workspace, + engine: AsyncTmuxEngine, + version: str | None, +) -> bool: + """Async sibling of :func:`_preflight_sync`.""" + result = await arun(HasSession(target=NameRef(ws.name)), engine, version=version) + if not result.exists: + return False + if ws.on_exists == "replace": + await arun(KillSession(target=NameRef(ws.name)), engine, version=version) + return False + if ws.on_exists == "reuse": + return True + msg = f"session {ws.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +def build_workspace( + ws: Workspace, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanResult: + """Compile and execute *ws* synchronously over *engine*. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace(name="dev", windows=[Window("w", panes=[Pane(run="vim")])]) + >>> build_workspace(ws, ConcreteEngine(), preflight=False).ok + True + """ + if preflight and _preflight_sync(ws, engine, version): + return PlanResult((), {}) + compiled = compile_full(ws, version=version) + for step in compiled.pre: + _run_host_sync(step) + bindings: dict[int | tuple[int, str], str] = {} + results: list[Result] = [] + for index, op in enumerate(compiled.plan.operations): + result = run(_resolve(op, bindings), engine, version=version) + results.append(result) + if result.created_id is not None: + bindings[index] = result.created_id + for part, sub in result.created_subids.items(): + bindings[index, part] = sub + for step in compiled.host_after.get(index, ()): + _run_host_sync(step) + return PlanResult(tuple(results), bindings) + + +async def abuild_workspace( + ws: Workspace, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanResult: + """Compile and execute *ws* asynchronously over *engine* (same resolution).""" + if preflight and await _preflight_async(ws, engine, version): + return PlanResult((), {}) + compiled = compile_full(ws, version=version) + for step in compiled.pre: + await _run_host_async(step) + bindings: dict[int | tuple[int, str], str] = {} + results: list[Result] = [] + for index, op in enumerate(compiled.plan.operations): + result = await arun(_resolve(op, bindings), engine, version=version) + results.append(result) + if result.created_id is not None: + bindings[index] = result.created_id + for part, sub in result.created_subids.items(): + bindings[index, part] = sub + for step in compiled.host_after.get(index, ()): + await _run_host_async(step) + return PlanResult(tuple(results), bindings) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py new file mode 100644 index 000000000..33eff2d77 --- /dev/null +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -0,0 +1,309 @@ +"""Declarative WorkspaceBuilder over the typed-ops Core, on real tmux. + +Replicates a tmuxp-style workspace build through the Declarative tier +(:mod:`libtmux.experimental.workspace`): a YAML/dict is analyzed into a structural +``Workspace`` spec, compiled to a Core ``LazyPlan``, and executed. Two tracks: + +* **offline** -- compile against the in-memory ``ConcreteEngine`` and assert the + op sequence and planner-equivalence (no tmux); +* **live** -- build over the async control-mode engine *and* the sync subprocess + engine against a real tmux server, then confirm the live structure matches the + spec. The same spec drives every engine and both sync and async. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ( + AsyncControlModeEngine, + ConcreteEngine, + SubprocessEngine, +) +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + MarkedPlanner, + SequentialPlanner, +) +from libtmux.experimental.workspace import ( + Workspace, + WorkspaceCompileError, + analyze, + confirm, +) +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from pathlib import Path + + from libtmux.experimental.ops.plan import PlanResult + from libtmux.session import Session + +_YAML = """ +session_name: ws-offline +start_directory: /tmp +windows: + - window_name: editor + layout: main-vertical + panes: + - echo top + - shell_command: + - echo bottom-1 + - echo bottom-2 + focus: true + - window_name: logs + focus: true + panes: + - echo logging +""" + + +def _spec(start_directory: str, name: str = "ws-live") -> Workspace: + """Return a two-window workspace spec rooted at *start_directory*.""" + return analyze( + { + "session_name": name, + "start_directory": start_directory, + "on_exists": "replace", + "windows": [ + { + "window_name": "editor", + "layout": "main-vertical", + "panes": [ + # First pane focused in a multi-pane window -- the case the + # spike broke; first-pane-id capture makes it work. + {"shell_command": ["echo top"], "focus": True}, + "echo bottom", + ], + }, + {"window_name": "logs", "focus": True, "panes": ["echo logging"]}, + ], + }, + ) + + +def test_workspace_analyze_normalizes_shorthand() -> None: + """The analyzer expands tmuxp shorthand into the canonical spec tree.""" + ws = analyze(_YAML) + assert ws.name == "ws-offline" + assert [w.name for w in ws.windows] == ["editor", "logs"] + assert ws.windows[0].panes[0].commands == ("echo top",) + assert ws.windows[0].panes[1].commands == ("echo bottom-1", "echo bottom-2") + assert ws.windows[0].panes[1].focus is True + + +def test_workspace_compiles_to_core_ops() -> None: + """Compiling the declared workspace emits Core ops in tmuxp-faithful order.""" + kinds = [op.kind for op in analyze(_YAML).compile().operations] + assert kinds[0] == "new_session" + assert kinds.count("new_window") == 1 # window 1 is reused, window 2 created + assert "split_window" in kinds + assert "select_layout" in kinds + assert kinds[-1] == "select_window" # window focus is emitted last + + +def test_workspace_offline_build_and_planner_equivalence() -> None: + """The compiled plan runs offline and the optimizer preserves the result.""" + plan = analyze(_YAML).compile() + sequential = plan.execute(ConcreteEngine(), planner=SequentialPlanner()) + folded = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + assert sequential.ok + assert folded.ok + assert [r.status for r in sequential.results] == [r.status for r in folded.results] + + +def test_first_pane_focus_multipane_compiles() -> None: + """Focusing the first pane of a multi-pane window now compiles (captured id).""" + ws = analyze( + { + "session_name": "ws-focus", + "windows": [ + { + "window_name": "w", + "panes": [{"shell_command": ["echo a"], "focus": True}, "echo b"], + }, + ], + }, + ) + plan = ws.compile() + assert "select_pane" in [op.kind for op in plan.operations] + # offline execution resolves the first-pane sub-ref without error + assert plan.execute(ConcreteEngine()).ok + + +def test_empty_workspace_is_rejected() -> None: + """A workspace with no windows fails closed at compile.""" + with pytest.raises(WorkspaceCompileError): + Workspace(name="empty").compile() + + +def test_workspace_builder_async_control_live( + session: Session, + tmp_path: Path, +) -> None: + """Build a workspace over the async control engine; confirm live structure.""" + server = session.server + spec = _spec(str(tmp_path), name="ws-async") + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await spec.abuild(engine) + + result = asyncio.run(main()) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + +def test_workspace_builder_subprocess_live( + session: Session, + tmp_path: Path, +) -> None: + """The same spec builds synchronously over the subprocess engine (neutrality).""" + server = session.server + spec = _spec(str(tmp_path), name="ws-sync") + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + +# --- Robust QA: a rich workspace exercising the full feature surface --- + + +def _rich_spec(start_directory: str, name: str = "ws-rich") -> Workspace: + """Return a three-window workspace: layouts, options, env, focus, multi-pane.""" + return analyze( + { + "session_name": name, + "start_directory": start_directory, + "on_exists": "replace", + "environment": {"WS_BUILDER": "1"}, + "options": {"history-limit": "5000"}, + "windows": [ + { + "window_name": "editor", + "layout": "main-vertical", + "options": {"main-pane-height": "12"}, + "panes": [ + {"shell_command": ["echo EDITORZERO"], "focus": True}, + "echo editor-one", + ], + }, + {"window_name": "logs", "panes": ["echo logs-zero"]}, + { + "window_name": "shell", + "focus": True, + "panes": [ + "echo shell-zero", + {"shell_command": ["echo SHELLONE"], "focus": True}, + "echo shell-two", + ], + }, + ], + }, + ) + + +def test_workspace_plan_serializes_round_trip(tmp_path: Path) -> None: + """The compiled plan (incl. SlotRef sub-refs) round-trips through to_list.""" + plan = _rich_spec(str(tmp_path)).compile() + data = plan.to_list() + restored = LazyPlan.from_list(data) + assert restored.to_list() == data + assert [o.kind for o in restored.operations] == [o.kind for o in plan.operations] + + +def test_workspace_all_planners_agree(tmp_path: Path) -> None: + """Sequential, Folding, and Marked planners give an identical PlanResult.""" + plan = _rich_spec(str(tmp_path)).compile() + runs = { + name: plan.execute(ConcreteEngine(), planner=planner()) + for name, planner in ( + ("sequential", SequentialPlanner), + ("folding", FoldingPlanner), + ("marked", MarkedPlanner), + ) + } + statuses = {name: [r.status for r in run.results] for name, run in runs.items()} + assert all(run.ok for run in runs.values()) + assert statuses["sequential"] == statuses["folding"] == statuses["marked"] + + +def test_workspace_builder_rich_subprocess(session: Session, tmp_path: Path) -> None: + """A rich workspace builds correctly: structure, focus, options, env, cwd, cmds.""" + server = session.server + spec = _rich_spec(str(tmp_path), name="ws-rich-sync") + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + built = server.sessions.filter(session_name="ws-rich-sync")[0] + windows = list(built.windows) + + # structure: names, order, per-window pane counts + assert [w.window_name for w in windows] == ["editor", "logs", "shell"] + assert [len(list(w.panes)) for w in windows] == [2, 1, 3] + + # window focus -> shell is the active window + assert built.active_window.window_name == "shell" + + # pane focus: editor's first pane + shell's middle pane are active in-window + editor, shell = windows[0], windows[2] + assert editor.active_pane is not None + assert editor.active_pane.pane_id == next(iter(editor.panes)).pane_id + assert shell.active_pane is not None + assert shell.active_pane.pane_id == list(shell.panes)[1].pane_id + + # session + window options applied + assert str(built.show_option("history-limit")) == "5000" + assert str(editor.show_option("main-pane-height")) == "12" + + # session environment set + assert built.show_environment().get("WS_BUILDER") == "1" + + # start_directory honored on a split pane (cwd settles after the shell starts) + want_cwd = str(tmp_path) + split_pane_id = list(editor.panes)[1].pane_id + + def _cwd_ok() -> bool: + pane = server.panes.get(pane_id=split_pane_id) + return pane is not None and pane.pane_current_path == want_cwd + + assert retry_until(_cwd_ok, 5, raises=False) + + # a command actually ran in the right pane + first_pane_id = next(iter(editor.panes)).pane_id + + def _cmd_ran() -> bool: + pane = server.panes.get(pane_id=first_pane_id) + return pane is not None and "EDITORZERO" in "\n".join(pane.capture_pane()) + + assert retry_until(_cmd_ran, 5, raises=False) + + +def test_workspace_builder_rich_async(session: Session, tmp_path: Path) -> None: + """The rich spec builds identically over the async control engine (neutrality).""" + server = session.server + spec = _rich_spec(str(tmp_path), name="ws-rich-async") + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await spec.abuild(engine) + + result = asyncio.run(main()) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + built = server.sessions.filter(session_name="ws-rich-async")[0] + assert [w.window_name for w in built.windows] == ["editor", "logs", "shell"] + assert built.active_window.window_name == "shell" + assert str(built.show_option("history-limit")) == "5000" From 3abc84b4f987b369ee858034dc1ba7906daff2b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 18:16:16 -0500 Subject: [PATCH 059/223] Ops(feat): Serialize bindings + add plan preview why: The MCP tier needs to round-trip a plan's forward-ref bindings through JSON (tuple keys are not JSON-native) and to dry-run a plan's argv without an engine. what: - serialize.bindings_to_dict / bindings_from_dict ((slot, part) <-> "slot:part") - LazyPlan.preview(): render each op's argv, None for unresolved SlotRefs --- src/libtmux/experimental/ops/plan.py | 26 ++++++++++++++++ src/libtmux/experimental/ops/serialize.py | 36 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index e530a9867..aad70b331 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -221,6 +221,32 @@ def add_chain(self, chain: OpChain) -> None: """Record every operation of an :class:`~._chain.OpChain` in order.""" self._operations.extend(chain.ops) + def preview(self, *, version: str | None = None) -> list[tuple[str, ...] | None]: + """Render each recorded operation's argv without executing it. + + A pure dry-run: an operation whose target is still an unresolved + :class:`~._types.SlotRef` renders as ``None`` (it needs a captured id + from an earlier step, supplied only at execution time). + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) + >>> _ = plan.add(SendKeys(target=pane, keys="vim", enter=True)) + >>> plan.preview() + [('split-window', '-t', '@1', '-v', '-P', '-F', '#{pane_id}'), None] + """ + + def _render(op: Operation[t.Any]) -> tuple[str, ...] | None: + try: + return op.render(version=version) + except TypeError: # unresolved SlotRef -- needs a captured id + return None + + return [_render(op) for op in self._operations] + def _drive( self, version: str | None, diff --git a/src/libtmux/experimental/ops/serialize.py b/src/libtmux/experimental/ops/serialize.py index e96de5ff2..bfbf4066e 100644 --- a/src/libtmux/experimental/ops/serialize.py +++ b/src/libtmux/experimental/ops/serialize.py @@ -179,3 +179,39 @@ def result_from_dict(data: Mapping[str, t.Any]) -> Result: continue kwargs[field.name] = _coerce_field(data[field.name]) return result_cls(**kwargs) + + +def bindings_to_dict(bindings: Mapping[int | tuple[int, str], str]) -> dict[str, str]: + """Serialize plan bindings to a JSON-friendly ``str``-keyed dict. + + A plain slot key ``N`` becomes ``"N"``; a sub-ref key ``(N, part)`` becomes + ``"N:part"`` (e.g. ``(0, "pane")`` -> ``"0:pane"``) so a forward-ref binding + survives a JSON round-trip. + + Examples + -------- + >>> bindings_to_dict({0: "$1", (0, "pane"): "%2"}) + {'0': '$1', '0:pane': '%2'} + """ + out: dict[str, str] = {} + for key, value in bindings.items(): + out[f"{key[0]}:{key[1]}" if isinstance(key, tuple) else str(key)] = value + return out + + +def bindings_from_dict(data: Mapping[str, str]) -> dict[int | tuple[int, str], str]: + """Reconstruct plan bindings from :func:`bindings_to_dict` output. + + Examples + -------- + >>> bindings_from_dict({"0": "$1", "0:pane": "%2"}) == {0: "$1", (0, "pane"): "%2"} + True + """ + out: dict[int | tuple[int, str], str] = {} + for key, value in data.items(): + if ":" in key: + slot, part = key.split(":", 1) + out[int(slot), part] = value + else: + out[int(key)] = value + return out From a740a045329f21305e5e8ec58e4221918f7c06e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 19:16:38 -0500 Subject: [PATCH 060/223] Mcp(feat): Add framework-agnostic tool projection why: Expose the typed-operations Core + Declarative tiers as a typed, chained, toolable command surface for agents, without coupling the library to any MCP framework. what: - experimental.mcp: ToolDescriptor/ParamDescriptor + OperationToolRegistry (per-op descriptors generated from the registry), an optional-pydantic schema builder, and TargetResolver (string/dict -> typed Target) - plan tools: preview_plan (dry-run), execute_plan (+ bindings), result_schema introspection, build_workspace - curated vocabulary: intuitive named tools mirroring libtmux's ORM (create_session/split_pane/send_input/list_*/kill_*/...) -> typed results - pure (ConcreteEngine) + live tmux tests; no fastmcp dependency --- src/libtmux/experimental/mcp/__init__.py | 98 +++++ src/libtmux/experimental/mcp/descriptor.py | 116 ++++++ src/libtmux/experimental/mcp/plan_tools.py | 144 +++++++ src/libtmux/experimental/mcp/registry.py | 166 ++++++++ src/libtmux/experimental/mcp/schema.py | 75 ++++ .../experimental/mcp/target_resolver.py | 87 ++++ src/libtmux/experimental/mcp/vocabulary.py | 373 ++++++++++++++++++ tests/experimental/mcp/__init__.py | 1 + tests/experimental/mcp/test_mcp_projection.py | 132 +++++++ tests/experimental/mcp/test_vocabulary.py | 107 +++++ 10 files changed, 1299 insertions(+) create mode 100644 src/libtmux/experimental/mcp/__init__.py create mode 100644 src/libtmux/experimental/mcp/descriptor.py create mode 100644 src/libtmux/experimental/mcp/plan_tools.py create mode 100644 src/libtmux/experimental/mcp/registry.py create mode 100644 src/libtmux/experimental/mcp/schema.py create mode 100644 src/libtmux/experimental/mcp/target_resolver.py create mode 100644 src/libtmux/experimental/mcp/vocabulary.py create mode 100644 tests/experimental/mcp/__init__.py create mode 100644 tests/experimental/mcp/test_mcp_projection.py create mode 100644 tests/experimental/mcp/test_vocabulary.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py new file mode 100644 index 000000000..eeaf2e73a --- /dev/null +++ b/src/libtmux/experimental/mcp/__init__.py @@ -0,0 +1,98 @@ +"""Framework-agnostic MCP projection: typed, chained, toolable tmux commands. + +The third tier over the Core (ops/plan/engines) and Declarative +(:mod:`libtmux.experimental.workspace`) tiers. It projects each operation into a +typed :class:`~.descriptor.ToolDescriptor` (via +:class:`~.registry.OperationToolRegistry`), resolves agent string/dict targets +(:func:`~.target_resolver.resolve_target`), and exposes plan tools +(:func:`~.plan_tools.preview_plan`, :func:`~.plan_tools.execute_plan`, +:func:`~.plan_tools.result_schema`) plus :func:`~.plan_tools.build_workspace`. + +It has **no** MCP-framework dependency (no fastmcp/pydantic at import time); a +thin adapter in a server (e.g. libtmux-mcp) binds these descriptors at runtime. +Everything here is experimental and outside the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> reg = OperationToolRegistry() +>>> reg.descriptor("new_session").safety +'mutating' +>>> resolve_target("%1") +PaneId(value='%1') +""" + +from __future__ import annotations + +from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor +from libtmux.experimental.mcp.plan_tools import ( + PlanOutcome, + PlanPreview, + ResultSchema, + aexecute_plan, + build_workspace, + execute_plan, + preview_plan, + result_schema, +) +from libtmux.experimental.mcp.registry import OperationToolRegistry +from libtmux.experimental.mcp.schema import schema_for_type +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary import ( + Listing, + PaneCapture, + PaneResult, + SessionResult, + WindowResult, + capture_pane, + create_session, + create_window, + kill_pane, + kill_session, + kill_window, + list_panes, + list_sessions, + list_windows, + rename_session, + rename_window, + select_layout, + select_pane, + send_input, + split_pane, +) + +__all__ = ( + "Listing", + "OperationToolRegistry", + "PaneCapture", + "PaneResult", + "ParamDescriptor", + "PlanOutcome", + "PlanPreview", + "ResultSchema", + "SessionResult", + "ToolDescriptor", + "WindowResult", + "aexecute_plan", + "build_workspace", + "capture_pane", + "create_session", + "create_window", + "execute_plan", + "kill_pane", + "kill_session", + "kill_window", + "list_panes", + "list_sessions", + "list_windows", + "preview_plan", + "rename_session", + "rename_window", + "resolve_target", + "result_schema", + "schema_for_type", + "select_layout", + "select_pane", + "send_input", + "split_pane", +) diff --git a/src/libtmux/experimental/mcp/descriptor.py b/src/libtmux/experimental/mcp/descriptor.py new file mode 100644 index 000000000..929a9c414 --- /dev/null +++ b/src/libtmux/experimental/mcp/descriptor.py @@ -0,0 +1,116 @@ +"""Framework-agnostic typed tool descriptors. + +A :class:`ToolDescriptor` is the projection of one tmux :class:`~..ops.operation. +Operation` into a tool: its name, typed parameters, safety annotations, result +schema, and a :meth:`~ToolDescriptor.build` factory that turns agent-supplied +params into a typed operation (resolving targets). It holds **no** MCP framework +object -- a thin adapter (fastmcp, click, …) binds it at runtime. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.mcp.target_resolver import resolve_target + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops.operation import Operation + +_JSON_TYPES = { + "int": "integer", + "float": "number", + "str": "string", + "bool": "boolean", + "list": "array", + "dict": "object", +} + + +@dataclass(frozen=True, slots=True) +class ParamDescriptor: + """One typed tool parameter, projected from an operation dataclass field.""" + + name: str + origin: str + is_required: bool = True + item_origin: str | None = None + description: str | None = None + version_gate: str | None = None + + def to_json_schema(self) -> dict[str, t.Any]: + """Render this parameter as a JSON-schema fragment. + + Examples + -------- + >>> p = ParamDescriptor("horizontal", "bool", description="split L/R") + >>> p.to_json_schema() + {'type': 'boolean', 'description': 'split L/R'} + """ + schema: dict[str, t.Any] = {"type": _JSON_TYPES.get(self.origin, "string")} + if self.origin == "list": + schema["items"] = { + "type": _JSON_TYPES.get(self.item_origin or "str", "string") + } + if self.description: + schema["description"] = self.description + return schema + + +@dataclass(frozen=True) +class ToolDescriptor: + """A typed tool projected from one operation -- metadata plus a builder. + + Parameters + ---------- + name, title, description + Identity and human text (``name`` is the operation ``kind``). + scope, safety + tmux object scope and the safety tier (drives annotations/tags). + params + Typed parameter descriptors (target/src_target handled by :meth:`build`). + result_type, result_schema + The result class name and a JSON schema for its payload. + annotations, tags + MCP-style hints derived from safety/effects. + operation_cls + The operation class :meth:`build` instantiates. + """ + + name: str + title: str + description: str + scope: str + safety: str + params: Mapping[str, ParamDescriptor] + result_type: str + result_schema: Mapping[str, t.Any] + annotations: Mapping[str, bool] + tags: frozenset[str] + version_gates: Mapping[str, str] + effects: Mapping[str, t.Any] + operation_cls: type[Operation[t.Any]] + + def input_schema(self) -> dict[str, t.Any]: + """Render the JSON schema for this tool's input object.""" + props = {name: param.to_json_schema() for name, param in self.params.items()} + required = [name for name, param in self.params.items() if param.is_required] + schema: dict[str, t.Any] = {"type": "object", "properties": props} + if required: + schema["required"] = required + return schema + + def build(self, **kwargs: t.Any) -> Operation[t.Any]: + """Construct the typed operation from agent params, resolving targets. + + ``target`` / ``src_target`` accept the polymorphic forms + :func:`~.target_resolver.resolve_target` understands; the rest are passed + through as operation fields (an unknown field fails closed via + ``TypeError``). + """ + fields = dict(kwargs) + target = resolve_target(fields.pop("target", None)) + src_target = resolve_target(fields.pop("src_target", None)) + return self.operation_cls(target=target, src_target=src_target, **fields) diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py new file mode 100644 index 000000000..bc5bc7101 --- /dev/null +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -0,0 +1,144 @@ +"""Plan-tier tools: preview a plan, execute it (with bindings), introspect. + +These wrap the Core :class:`~..ops.plan.LazyPlan` for an agent: a pure dry-run, a +typed execution that returns JSON-serialisable per-op results plus a forward-ref +``bindings`` map, and a result-schema query so an agent can learn what ids a step +will yield *before* composing the next step. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.serialize import ( + bindings_to_dict, + operation_to_dict, + result_to_dict, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.mcp.registry import OperationToolRegistry + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.planner import Planner + + +@dataclass(frozen=True) +class PlanPreview: + """A pure dry-run of a plan: per-op dicts + rendered argv (or ``None``).""" + + operations: list[dict[str, t.Any]] + argv: list[tuple[str, ...] | None] + + @property + def ok(self) -> bool: + """Whether every operation rendered (no unresolved forward refs).""" + return all(item is not None for item in self.argv) + + +def preview_plan(plan: LazyPlan, *, version: str | None = None) -> PlanPreview: + """Render a plan without executing it (forward-ref steps render as ``None``).""" + return PlanPreview( + operations=[operation_to_dict(op) for op in plan.operations], + argv=plan.preview(version=version), + ) + + +@dataclass(frozen=True) +class PlanOutcome: + """The result of executing a plan: per-op result dicts + a bindings map.""" + + ok: bool + results: list[dict[str, t.Any]] + bindings: dict[str, str] + + +def execute_plan( + plan: LazyPlan, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, +) -> PlanOutcome: + """Execute *plan* over *engine*; return JSON-friendly results + bindings.""" + result = plan.execute(engine, version=version, planner=planner) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) + + +async def aexecute_plan( + plan: LazyPlan, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, +) -> PlanOutcome: + """Async sibling of :func:`execute_plan` (same shape).""" + result = await plan.aexecute(engine, version=version, planner=planner) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) + + +@dataclass(frozen=True) +class ResultSchema: + """A result type's schema + the fields an agent can bind downstream.""" + + kind: str + result_type: str + schema: dict[str, t.Any] + binding_fields: list[str] + + +def result_schema(registry: OperationToolRegistry, kind: str) -> ResultSchema: + """Introspect what *kind* returns -- so an agent can plan forward refs. + + ``binding_fields`` are the result fields carrying ids an agent would reference + in a later step (``*_id`` / ``new_id``); they are read from the result + dataclass directly, so they do not depend on the JSON-schema backend. + """ + import dataclasses + + from libtmux.experimental.ops import registry as ops_registry + + descriptor = registry.descriptor(kind) + fields = [ + field.name for field in dataclasses.fields(ops_registry.get(kind).result_cls) + ] + binding_fields = [ + name for name in fields if name == "new_id" or name.endswith("_id") + ] + return ResultSchema( + kind=kind, + result_type=descriptor.result_type, + schema=dict(descriptor.result_schema), + binding_fields=binding_fields, + ) + + +def build_workspace( + spec: t.Mapping[str, t.Any] | str, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanOutcome: + """Build a declarative workspace (the Declarative tier) as one tool call. + + *spec* is a tmux-style mapping or YAML string (see + :func:`~..workspace.analyzer.analyze`). + """ + from libtmux.experimental.workspace import analyze + + result = analyze(spec).build(engine, version=version, preflight=preflight) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) diff --git a/src/libtmux/experimental/mcp/registry.py b/src/libtmux/experimental/mcp/registry.py new file mode 100644 index 000000000..6f153d35e --- /dev/null +++ b/src/libtmux/experimental/mcp/registry.py @@ -0,0 +1,166 @@ +"""Generate :class:`~.descriptor.ToolDescriptor` values from the op registry. + +One descriptor per registered operation ``kind``, derived by introspecting the +operation dataclass (fields + type hints + NumPy-docstring params) and its +``OpSpec`` metadata (scope/safety/effects/version gates). Zero MCP-framework +coupling: the result is plain data + a builder. +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor +from libtmux.experimental.mcp.schema import schema_for_type +from libtmux.experimental.ops import registry as ops_registry + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.registry import OpSpec + +_ANNOTATIONS: dict[str, dict[str, bool]] = { + "readonly": {"readOnlyHint": True}, + "mutating": {"readOnlyHint": False}, + "destructive": {"readOnlyHint": False, "destructiveHint": True}, +} +_SKIP_FIELDS = frozenset({"target", "src_target"}) +_SCALAR_NAME = {"bool": "bool", "int": "int", "float": "float", "str": "str"} +_LIST_BASES = frozenset({"list", "tuple", "Sequence", "frozenset", "set"}) +_DICT_BASES = frozenset({"dict", "Mapping", "MutableMapping"}) + + +def _origin_of(annotation: t.Any) -> tuple[str, str | None]: + """Map a field annotation to a ``(origin, item_origin)`` schema pair. + + Parses the annotation *string* (operations use ``from __future__ import + annotations``, and their hints reference ``TYPE_CHECKING``-only names like + ``Mapping`` that ``get_type_hints`` cannot resolve at runtime), so it never + needs to import the annotated types. + """ + text = ( + annotation + if isinstance(annotation, str) + else getattr(annotation, "__name__", str(annotation)) + ) + text = text.replace(" ", "") + if "|" in text: + members = [member for member in text.split("|") if member and member != "None"] + text = members[0] if members else "str" + base = text.split("[", 1)[0] + if base in _LIST_BASES: + inner = text[len(base) + 1 : -1] if "[" in text else "" + item = inner.split("[", 1)[0].split(",", 1)[0] if inner else "str" + return "list", _SCALAR_NAME.get(item, "str") + if base in _DICT_BASES: + return "dict", None + return _SCALAR_NAME.get(base, "str"), None + + +def _docstring_params(doc: str | None) -> dict[str, str]: + """Parse ``name : type`` entries from a NumPy docstring Parameters block.""" + if not doc: + return {} + out: dict[str, str] = {} + in_params = False + pending: str | None = None + for raw in doc.splitlines(): + line = raw.rstrip() + if line.strip() in {"Parameters", "Attributes"}: + in_params = True + continue + if not in_params: + continue + if line.strip().startswith(("Returns", "Examples", "Notes", "Raises")): + break + if " : " in line and not line.startswith(" "): + pending = line.split(" : ", 1)[0].strip() + elif pending and line.startswith(" ") and line.strip(): + out.setdefault(pending, line.strip()) + pending = None + return out + + +def _summary(doc: str | None) -> str: + """Return the first non-empty docstring line.""" + for line in (doc or "").splitlines(): + if line.strip(): + return line.strip() + return "" + + +class OperationToolRegistry: + """Build (and cache) a :class:`~.descriptor.ToolDescriptor` per operation. + + Examples + -------- + >>> reg = OperationToolRegistry() + >>> d = reg.descriptor("split_window") + >>> d.name, d.scope, d.safety + ('split_window', 'window', 'mutating') + >>> d.params["horizontal"].origin + 'bool' + >>> d.build(target="@1", horizontal=True).render() + ('split-window', '-t', '@1', '-h', '-P', '-F', '#{pane_id}') + >>> len(reg.descriptors()) == len(list(reg.kinds())) + True + """ + + def __init__(self) -> None: + self._cache: dict[str, ToolDescriptor] = {} + + def kinds(self) -> tuple[str, ...]: + """Return every registered operation kind, sorted.""" + return ops_registry.kinds() + + def descriptor(self, kind: str) -> ToolDescriptor: + """Return (building + caching) the descriptor for *kind*.""" + cached = self._cache.get(kind) + if cached is not None: + return cached + built = self._build(ops_registry.get(kind)) + self._cache[kind] = built + return built + + def descriptors(self) -> list[ToolDescriptor]: + """Return a descriptor for every registered operation, sorted by name.""" + return [self.descriptor(spec.kind) for spec in ops_registry.select()] + + def _build(self, spec: OpSpec) -> ToolDescriptor: + """Project one ``OpSpec`` into a tool descriptor.""" + return ToolDescriptor( + name=spec.kind, + title=spec.kind.replace("_", " ").title(), + description=_summary(spec.operation_cls.__doc__), + scope=spec.scope, + safety=spec.safety, + params=self._params(spec), + result_type=spec.result_cls.__name__, + result_schema=schema_for_type(spec.result_cls), + annotations=_ANNOTATIONS.get(spec.safety, {}), + tags=frozenset({spec.safety}), + version_gates=dict(spec.flag_version_map), + effects=dataclasses.asdict(spec.effects), + operation_cls=spec.operation_cls, + ) + + def _params(self, spec: OpSpec) -> dict[str, ParamDescriptor]: + """Extract typed parameter descriptors from the operation's fields.""" + operation_cls = spec.operation_cls + docs = _docstring_params(operation_cls.__doc__) + params: dict[str, ParamDescriptor] = {} + for field in dataclasses.fields(operation_cls): + if field.name in _SKIP_FIELDS: + continue + origin, item = _origin_of(field.type) + params[field.name] = ParamDescriptor( + name=field.name, + origin=origin, + item_origin=item, + is_required=( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ), + description=docs.get(field.name), + version_gate=spec.flag_version_map.get(field.name), + ) + return params diff --git a/src/libtmux/experimental/mcp/schema.py b/src/libtmux/experimental/mcp/schema.py new file mode 100644 index 000000000..cedbfe811 --- /dev/null +++ b/src/libtmux/experimental/mcp/schema.py @@ -0,0 +1,75 @@ +"""Best-effort JSON-schema generation for result types. + +The single place pydantic is *optional*: if installed, its ``TypeAdapter`` gives a +precise schema; otherwise a small stdlib introspection produces a serviceable one. +Either way the projection core has no hard pydantic dependency. +""" + +from __future__ import annotations + +import collections.abc +import dataclasses +import typing as t + +_SCALARS: dict[type, str] = { + int: "integer", + float: "number", + str: "string", + bool: "boolean", +} + + +def schema_for_type(tp: type) -> dict[str, t.Any]: + """Return a JSON-schema dict for *tp* (pydantic if available, else stdlib). + + Examples + -------- + >>> schema_for_type(int) + {'type': 'integer'} + >>> schema_for_type(str) + {'type': 'string'} + """ + import importlib + + try: + type_adapter = importlib.import_module("pydantic").TypeAdapter + except ImportError: + return _introspect(tp) + try: + return dict(type_adapter(tp).json_schema()) + except Exception: # pydantic rejects some dataclasses -- fall back to stdlib + return _introspect(tp) + + +def _introspect(tp: t.Any) -> dict[str, t.Any]: + """Render a coarse JSON schema by walking dataclass fields / generics.""" + if dataclasses.is_dataclass(tp) and isinstance(tp, type): + try: + hints = t.get_type_hints(tp) + except Exception: + hints = {} + props: dict[str, t.Any] = {} + required: list[str] = [] + for field in dataclasses.fields(tp): + if field.name == "operation": # back-reference to the source op, not output + continue + props[field.name] = _introspect(hints.get(field.name, str)) + if ( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ): + required.append(field.name) + schema: dict[str, t.Any] = {"type": "object", "properties": props} + if required: + schema["required"] = required + return schema + if tp in _SCALARS: + return {"type": _SCALARS[tp]} + origin = t.get_origin(tp) + if origin in (list, tuple): + args = t.get_args(tp) + item = _introspect(args[0]) if args else {"type": "string"} + return {"type": "array", "items": item} + if origin in (dict, collections.abc.Mapping): + return {"type": "object"} + return {"type": "string"} diff --git a/src/libtmux/experimental/mcp/target_resolver.py b/src/libtmux/experimental/mcp/target_resolver.py new file mode 100644 index 000000000..000c9b59e --- /dev/null +++ b/src/libtmux/experimental/mcp/target_resolver.py @@ -0,0 +1,87 @@ +"""Resolve agent-supplied targets to typed :data:`~..ops._types.Target` values. + +The string/dict boundary between an MCP client (which speaks JSON) and the typed +operation spine. Fail-closed: an unrecognised target raises rather than guessing. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.serialize import target_from_dict + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Target + +_TARGET_CLASSES = ( + PaneId, + WindowId, + SessionId, + ClientName, + NameRef, + IndexRef, + Special, + SlotRef, +) + + +def resolve_target(value: str | Mapping[str, t.Any] | Target | None) -> Target | None: + """Coerce a target spec into a typed :data:`~..ops._types.Target`. + + Accepts an already-typed target (passthrough), the tagged dict form from + :func:`~..ops.serialize.target_to_dict`, ``None``, or a string using tmux + sigils: ``%``→pane, ``@``→window, ``$``→session, ``/``→client, ``{...}``→ + special, ``=name``→exact name, otherwise a prefix-matched name. + + Examples + -------- + >>> resolve_target("%1") + PaneId(value='%1') + >>> resolve_target("@2") + WindowId(value='@2') + >>> resolve_target("work") + NameRef(name='work', exact=False) + >>> resolve_target({"type": "PaneId", "value": "%3"}) + PaneId(value='%3') + >>> resolve_target(None) is None + True + """ + if value is None: + return None + if isinstance(value, _TARGET_CLASSES): + return value + if isinstance(value, str): + return _from_string(value) + return target_from_dict(value) + + +def _from_string(value: str) -> Target: + """Parse a target string by its tmux sigil (fail-closed on empty).""" + if not value: + msg = "empty target string" + raise ValueError(msg) + if value.startswith("%"): + return PaneId(value) + if value.startswith("@"): + return WindowId(value) + if value.startswith("$"): + return SessionId(value) + if value.startswith("/"): + return ClientName(value) + if value.startswith("{") and value.endswith("}"): + return Special(value) + if value.startswith("="): + return NameRef(value[1:], exact=True) + return NameRef(value) diff --git a/src/libtmux/experimental/mcp/vocabulary.py b/src/libtmux/experimental/mcp/vocabulary.py new file mode 100644 index 000000000..ec44f519a --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary.py @@ -0,0 +1,373 @@ +"""Curated core vocabulary -- the intuitive, named tmux tools. + +The Layer-1 surface: a small set of hand-written, framework-agnostic functions +that mirror libtmux's own ORM (``server.new_session`` / ``window.split_window`` / +``pane.send_keys``) but run over any engine and return small, typed result +objects exposing just the ids/names a caller cares about. Each is a thin wrapper: +resolve the target, build one operation, :func:`~..ops.execute.run` it, raise on +failure, return a typed result. Power users drop to the per-op descriptors, +plans, or ops directly. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> engine = ConcreteEngine() +>>> session = create_session(engine, name="dev") +>>> session.session_id +'$1' +>>> pane = split_pane(engine, session.first_pane_id or "%1", horizontal=True) +>>> pane.pane_id +'%2' +>>> send_input(engine, pane.pane_id, "pytest -q", enter=True) is None +True +""" + +from __future__ import annotations + +import collections.abc +from dataclasses import dataclass + +from libtmux.experimental.engines.base import TmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.ops import ( + CapturePane, + KillPane, + KillSession, + KillWindow, + ListPanes, + ListSessions, + ListWindows, + NewSession, + NewWindow, + RenameSession, + RenameWindow, + SelectLayout, + SelectPane, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import Target + +# TmuxEngine / Target are imported at runtime (not under TYPE_CHECKING) so the +# fastmcp adapter's get_type_hints() can resolve these annotations when it builds +# tool schemas from these functions. + + +@dataclass(frozen=True) +class SessionResult: + """A created session: its id, name, and captured first window/pane ids.""" + + session_id: str + name: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class WindowResult: + """A created window: its id, name, and captured first pane id.""" + + window_id: str + name: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class PaneResult: + """A created pane: its id.""" + + pane_id: str + + +@dataclass(frozen=True) +class PaneCapture: + """Captured pane contents.""" + + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class Listing: + """A list query result: one mapping (tmux format row) per object.""" + + rows: tuple[collections.abc.Mapping[str, str], ...] + + +def create_session( + engine: TmuxEngine, + *, + name: str | None = None, + start_directory: str | None = None, + environment: collections.abc.Mapping[str, str] | None = None, + width: int | None = None, + height: int | None = None, + version: str | None = None, +) -> SessionResult: + """Create a detached session (mirrors ``server.new_session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> r = create_session(ConcreteEngine(), name="work") + >>> (r.session_id, r.name, r.first_pane_id) + ('$1', 'work', '%1') + """ + result = run( + NewSession( + session_name=name, + start_directory=start_directory, + environment=environment, + width=width, + height=height, + capture_panes=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return SessionResult( + session_id=result.new_id or "", + name=name, + first_window_id=result.first_window_id, + first_pane_id=result.first_pane_id, + ) + + +def create_window( + engine: TmuxEngine, + target: str | Target, + *, + name: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> WindowResult: + """Create a window in a session (mirrors ``session.new_window``).""" + result = run( + NewWindow( + target=resolve_target(target), + name=name, + start_directory=start_directory, + capture_pane=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult( + window_id=result.new_id or "", + name=name, + first_pane_id=result.first_pane_id, + ) + + +def split_pane( + engine: TmuxEngine, + target: str | Target, + *, + horizontal: bool = False, + start_directory: str | None = None, + version: str | None = None, +) -> PaneResult: + """Split a pane, creating a new one (mirrors ``window.split_window``).""" + result = run( + SplitWindow( + target=resolve_target(target), + horizontal=horizontal, + start_directory=start_directory, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneResult(pane_id=result.new_pane_id or "") + + +def send_input( + engine: TmuxEngine, + target: str | Target, + keys: str, + *, + enter: bool = False, + literal: bool = False, + suppress_history: bool = False, + version: str | None = None, +) -> None: + """Send keys to a pane (mirrors ``pane.send_keys``).""" + run( + SendKeys( + target=resolve_target(target), + keys=keys, + enter=enter, + literal=literal, + suppress_history=suppress_history, + ), + engine, + version=version, + ).raise_for_status() + + +def capture_pane( + engine: TmuxEngine, + target: str | Target, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture a pane's contents (mirrors ``pane.capture_pane``).""" + result = run( + CapturePane( + target=resolve_target(target), + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +def list_sessions(engine: TmuxEngine, *, version: str | None = None) -> Listing: + """List the server's sessions (mirrors ``server.sessions``).""" + result = run(ListSessions(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +def list_windows( + engine: TmuxEngine, + target: str | Target | None = None, + *, + all_windows: bool = False, + version: str | None = None, +) -> Listing: + """List windows of a session, or all windows (mirrors ``session.windows``).""" + result = run( + ListWindows(target=resolve_target(target), all_windows=all_windows), + engine, + version=version, + ) + result.raise_for_status() + return Listing(rows=result.rows) + + +def list_panes( + engine: TmuxEngine, + target: str | Target | None = None, + *, + all_panes: bool = False, + version: str | None = None, +) -> Listing: + """List panes of a window, or all panes (mirrors ``window.panes``).""" + result = run( + ListPanes(target=resolve_target(target), all_panes=all_panes), + engine, + version=version, + ) + result.raise_for_status() + return Listing(rows=result.rows) + + +def kill_pane( + engine: TmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a pane (or all others in its window with ``others=True``).""" + run( + KillPane(target=resolve_target(target), others=others), + engine, + version=version, + ).raise_for_status() + + +def kill_window( + engine: TmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a window (or all others in its session with ``others=True``).""" + run( + KillWindow(target=resolve_target(target), others=others), + engine, + version=version, + ).raise_for_status() + + +def kill_session( + engine: TmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Kill a session (mirrors ``session.kill``).""" + run( + KillSession(target=resolve_target(target)), engine, version=version + ).raise_for_status() + + +def rename_window( + engine: TmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a window (mirrors ``window.rename_window``).""" + run( + RenameWindow(target=resolve_target(target), name=name), + engine, + version=version, + ).raise_for_status() + + +def rename_session( + engine: TmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a session (mirrors ``session.rename_session``).""" + run( + RenameSession(target=resolve_target(target), name=name), + engine, + version=version, + ).raise_for_status() + + +def select_layout( + engine: TmuxEngine, + target: str | Target, + *, + layout: str | None = None, + version: str | None = None, +) -> None: + """Apply a layout to a window (mirrors ``window.select_layout``).""" + run( + SelectLayout(target=resolve_target(target), layout=layout), + engine, + version=version, + ).raise_for_status() + + +def select_pane( + engine: TmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Make a pane active (mirrors ``window.select_pane``).""" + run( + SelectPane(target=resolve_target(target)), engine, version=version + ).raise_for_status() diff --git a/tests/experimental/mcp/__init__.py b/tests/experimental/mcp/__init__.py new file mode 100644 index 000000000..83655d469 --- /dev/null +++ b/tests/experimental/mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the framework-agnostic MCP projection tier.""" diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py new file mode 100644 index 000000000..237b6d0d6 --- /dev/null +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -0,0 +1,132 @@ +"""The framework-agnostic MCP projection tier (no fastmcp required). + +Exercises descriptor generation from the operation registry, agent target +resolution, plan preview/execute with forward-ref bindings, result-schema +introspection, and the build_workspace tool -- all against the in-memory +``ConcreteEngine`` so the projection is provably correct offline. +""" + +from __future__ import annotations + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.mcp import ( + OperationToolRegistry, + build_workspace, + execute_plan, + preview_plan, + resolve_target, + result_schema, +) +from libtmux.experimental.ops import ( + LazyPlan, + NewSession, + SendKeys, + SplitWindow, + registry, +) +from libtmux.experimental.ops._types import NameRef, PaneId, SessionId, WindowId +from libtmux.experimental.ops.serialize import bindings_from_dict, bindings_to_dict + + +def test_every_operation_has_a_descriptor() -> None: + """The registry projects one valid descriptor per registered operation kind.""" + reg = OperationToolRegistry() + descriptors = reg.descriptors() + assert {d.name for d in descriptors} == set(registry.kinds()) + for d in descriptors: + schema = d.input_schema() + assert schema["type"] == "object" + assert d.safety in {"readonly", "mutating", "destructive"} + + +def test_split_window_descriptor_shape() -> None: + """A per-op descriptor carries typed params, scope, safety, and annotations.""" + desc = OperationToolRegistry().descriptor("split_window") + assert desc.name == "split_window" + assert desc.scope == "window" + assert desc.safety == "mutating" + assert desc.annotations == {"readOnlyHint": False} + assert desc.result_type == "SplitWindowResult" + assert desc.params["horizontal"].origin == "bool" + assert desc.params["horizontal"].is_required is False + + +def test_readonly_op_annotation() -> None: + """A readonly operation projects a readOnlyHint annotation + tag.""" + desc = OperationToolRegistry().descriptor("has_session") + assert desc.annotations == {"readOnlyHint": True} + assert "readonly" in desc.tags + + +def test_descriptor_build_resolves_targets() -> None: + """ToolDescriptor.build turns agent params into a typed operation.""" + desc = OperationToolRegistry().descriptor("split_window") + op = desc.build(target="@1", horizontal=True) + assert isinstance(op, SplitWindow) + assert op.target == WindowId("@1") + assert op.render() == ("split-window", "-t", "@1", "-h", "-P", "-F", "#{pane_id}") + + +def test_resolve_target_forms() -> None: + """resolve_target coerces every supported spec into a typed Target.""" + assert resolve_target("%1") == PaneId("%1") + assert resolve_target("@2") == WindowId("@2") + assert resolve_target("$0") == SessionId("$0") + assert resolve_target("work") == NameRef("work") + assert resolve_target({"type": "PaneId", "value": "%3"}) == PaneId("%3") + assert resolve_target(PaneId("%4")) == PaneId("%4") + assert resolve_target(None) is None + + +def test_bindings_round_trip() -> None: + """Plan bindings (incl. sub-ref tuple keys) survive a JSON round-trip.""" + original: dict[int | tuple[int, str], str] = {0: "$1", (0, "pane"): "%2", 1: "@3"} + assert bindings_from_dict(bindings_to_dict(original)) == original + + +def test_preview_plan_marks_unresolved_forward_refs() -> None: + """preview_plan renders a pure dry-run; forward-ref steps render as None.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + preview = preview_plan(plan) + assert preview.argv[0] is not None + assert preview.argv[1] is None # SendKeys targets the not-yet-created pane + assert preview.ok is False + + +def test_execute_plan_returns_bindings() -> None: + """execute_plan resolves forward refs and returns a JSON bindings map.""" + plan = LazyPlan() + session = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.add(SendKeys(target=session.pane, keys="vim", enter=True)) + outcome = execute_plan(plan, ConcreteEngine()) + assert outcome.ok + assert outcome.bindings["0"].startswith("$") + assert outcome.bindings["0:pane"].startswith("%") + assert outcome.results[1]["argv"][0] == "send-keys" + + +def test_result_schema_introspection() -> None: + """result_schema reports the id fields an agent can bind downstream.""" + split = result_schema(OperationToolRegistry(), "split_window") + assert split.result_type == "SplitWindowResult" + assert "new_pane_id" in split.binding_fields + + session = result_schema(OperationToolRegistry(), "new_session") + assert "first_pane_id" in session.binding_fields + assert "first_window_id" in session.binding_fields + + +def test_build_workspace_tool_offline() -> None: + """build_workspace runs the declarative tier as one tool call (offline).""" + outcome = build_workspace( + { + "session_name": "dev", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + }, + ConcreteEngine(), + preflight=False, + ) + assert outcome.ok + assert outcome.bindings["0"].startswith("$") diff --git a/tests/experimental/mcp/test_vocabulary.py b/tests/experimental/mcp/test_vocabulary.py new file mode 100644 index 000000000..790112953 --- /dev/null +++ b/tests/experimental/mcp/test_vocabulary.py @@ -0,0 +1,107 @@ +"""The curated core vocabulary -- intuitive named tmux tools. + +Pure tests run the vocabulary against the in-memory ``ConcreteEngine`` (no tmux); +a live test drives a real tmux server end to end (create -> window -> split -> +send -> capture -> rename -> kill) over the subprocess engine. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp import ( + capture_pane, + create_session, + create_window, + kill_session, + list_panes, + list_sessions, + list_windows, + rename_window, + send_input, + split_pane, +) +from libtmux.experimental.ops._types import SessionId +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from pathlib import Path + + from libtmux.session import Session + + +def test_create_session_returns_typed_result() -> None: + """create_session yields a typed result with the captured first pane id.""" + result = create_session(ConcreteEngine(), name="dev") + assert result.session_id == "$1" + assert result.name == "dev" + assert result.first_window_id == "@1" + assert result.first_pane_id == "%1" + + +def test_create_window_then_split() -> None: + """create_window captures a first pane id that split_pane can target.""" + engine = ConcreteEngine() + session = create_session(engine, name="dev") + window = create_window(engine, session.session_id, name="logs") + assert window.window_id.startswith("@") + assert window.first_pane_id is not None + pane = split_pane(engine, window.first_pane_id, horizontal=True) + assert pane.pane_id.startswith("%") + + +def test_send_input_is_fire_and_forget() -> None: + """send_input runs without returning a value (and without raising).""" + send_input(ConcreteEngine(), "%1", "echo hi", enter=True) + + +def test_capture_pane_returns_lines() -> None: + """capture_pane surfaces the pane's lines.""" + engine = ConcreteEngine(capture_lines=("line-1", "line-2")) + assert capture_pane(engine, "%1").lines == ("line-1", "line-2") + + +def test_list_tools_return_listings() -> None: + """The list_* tools return a Listing of format rows.""" + engine = ConcreteEngine() + assert isinstance(list_sessions(engine).rows, tuple) + assert isinstance(list_windows(engine).rows, tuple) + assert isinstance(list_panes(engine).rows, tuple) + + +def test_target_accepts_string_or_typed() -> None: + """A vocabulary target may be a string or an already-typed Target.""" + engine = ConcreteEngine() + assert create_window(engine, "$1").window_id.startswith("@") + assert create_window(engine, SessionId("$1")).window_id.startswith("@") + + +def test_vocabulary_live(session: Session, tmp_path: Path) -> None: + """Drive a real tmux server through the curated vocabulary end to end.""" + server = session.server + engine = SubprocessEngine.for_server(server) + + created = create_session(engine, name="vocab-live", start_directory=str(tmp_path)) + try: + assert server.sessions.filter(session_name="vocab-live") + assert created.first_pane_id is not None + + window = create_window(engine, created.session_id, name="extra") + assert window.first_pane_id is not None + pane = split_pane(engine, window.first_pane_id, horizontal=True) + send_input(engine, pane.pane_id, "echo VOCABMARK", enter=True) + + def _ran() -> bool: + live = server.panes.get(pane_id=pane.pane_id) + return live is not None and "VOCABMARK" in "\n".join(live.capture_pane()) + + assert retry_until(_ran, 5, raises=False) + + rename_window(engine, window.window_id, "renamed") + renamed = server.windows.get(window_id=window.window_id) + assert renamed is not None + assert renamed.window_name == "renamed" + finally: + kill_session(engine, created.session_id) + assert not server.sessions.filter(session_name="vocab-live") From 5b92dfe1aeb5884835adcc71195ea56aebc24632 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 20:26:28 -0500 Subject: [PATCH 061/223] Mcp(feat): Add optional fastmcp adapter (libtmux[mcp]) why: Prove the projection drives a real MCP server and give downstream servers a one-call binding -- behind an optional extra so the core stays dependency-free. what: - fastmcp_adapter.build_server(engine): register the curated vocabulary as typed FastMCP tools (engine bound out of the schema, safety -> ToolAnnotations) - add fastmcp to a new [project.optional-dependencies] mcp extra - in-process tests (offline + live) call the tools via fastmcp's Client --- pyproject.toml | 5 + .../experimental/mcp/fastmcp_adapter.py | 127 ++ .../experimental/mcp/test_fastmcp_adapter.py | 90 ++ uv.lock | 1301 ++++++++++++++++- 4 files changed, 1516 insertions(+), 7 deletions(-) create mode 100644 src/libtmux/experimental/mcp/fastmcp_adapter.py create mode 100644 tests/experimental/mcp/test_fastmcp_adapter.py diff --git a/pyproject.toml b/pyproject.toml index a474735c5..02f9cc076 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,11 @@ lint = [ [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +[project.optional-dependencies] +mcp = [ + "fastmcp>=3.4.2", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py new file mode 100644 index 000000000..c172e0788 --- /dev/null +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -0,0 +1,127 @@ +"""Optional fastmcp adapter -- expose the curated vocabulary on a FastMCP server. + +This is the thin, framework-specific edge. It requires the ``mcp`` extra +(``pip install libtmux[mcp]``); fastmcp is imported lazily so the rest of +:mod:`libtmux.experimental.mcp` stays dependency-free. The agent-facing ``engine`` +is bound out of each tool's schema with :func:`functools.partial`, and the safety +tier becomes the tool's ``ToolAnnotations`` + tag. + +Examples +-------- +>>> import asyncio +>>> from fastmcp import Client # doctest: +SKIP +>>> from libtmux.experimental.engines import ConcreteEngine +>>> server = build_server(ConcreteEngine()) # doctest: +SKIP +>>> async def main(): # doctest: +SKIP +... async with Client(server) as client: +... return (await client.call_tool("create_session", {"name": "dev"})).data +>>> asyncio.run(main()) # doctest: +SKIP +""" + +from __future__ import annotations + +import inspect +import typing as t + +from libtmux.experimental.mcp import vocabulary + +if t.TYPE_CHECKING: + from collections.abc import Callable + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import TmuxEngine + +# (function, safety tier) -- every curated tool takes ``engine`` as its first arg. +_VOCABULARY: tuple[tuple[Callable[..., t.Any], str], ...] = ( + (vocabulary.create_session, "mutating"), + (vocabulary.create_window, "mutating"), + (vocabulary.split_pane, "mutating"), + (vocabulary.send_input, "mutating"), + (vocabulary.capture_pane, "readonly"), + (vocabulary.list_sessions, "readonly"), + (vocabulary.list_windows, "readonly"), + (vocabulary.list_panes, "readonly"), + (vocabulary.rename_window, "mutating"), + (vocabulary.rename_session, "mutating"), + (vocabulary.select_layout, "mutating"), + (vocabulary.select_pane, "mutating"), + (vocabulary.kill_pane, "destructive"), + (vocabulary.kill_window, "destructive"), + (vocabulary.kill_session, "destructive"), +) + +_INSTRUCTIONS = ( + "Drive tmux through typed tools. Targets accept tmux ids (%pane, @window, " + "$session), names, or 'session:window.pane'. Creating a session/window also " + "returns the new first-pane id for chaining." +) + + +def _summary(doc: str | None) -> str | None: + """Return the first non-empty docstring line.""" + for line in (doc or "").splitlines(): + if line.strip(): + return line.strip() + return None + + +def _bind_engine( + fn: Callable[..., t.Any], + engine: TmuxEngine, +) -> Callable[..., t.Any]: + """Bind *engine* out of *fn*, returning a wrapper fastmcp can introspect. + + Unlike :func:`functools.partial`, this carries *pre-resolved* annotations + (with ``engine`` removed) and an explicit ``__signature__``, so fastmcp's + ``get_type_hints`` never has to re-evaluate the forward references in *fn* + (it would do so against the wrong module globals and fail). + """ + hints = t.get_type_hints(fn) + signature = inspect.signature(fn) + params = [p for name, p in signature.parameters.items() if name != "engine"] + + def tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + return fn(engine, *args, **kwargs) + + tool.__name__ = fn.__name__ + tool.__qualname__ = fn.__name__ + tool.__doc__ = fn.__doc__ + tool.__signature__ = signature.replace(parameters=params) # type: ignore[attr-defined] + tool.__annotations__ = {k: v for k, v in hints.items() if k != "engine"} + return tool + + +def register_vocabulary(mcp: FastMCP, engine: TmuxEngine) -> None: + """Register the curated vocabulary as tools on *mcp*, bound to *engine*.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + for fn, safety in _VOCABULARY: + annotations = ToolAnnotations( + title=fn.__name__, + readOnlyHint=safety == "readonly", + destructiveHint=safety == "destructive", + ) + tool = FunctionTool.from_function( + _bind_engine(fn, engine), + name=fn.__name__, + description=_summary(fn.__doc__), + tags={safety}, + annotations=annotations, + ) + mcp.add_tool(tool) + + +def build_server( + engine: TmuxEngine, + *, + name: str = "libtmux", + instructions: str | None = None, +) -> FastMCP: + """Build a FastMCP server exposing the curated vocabulary over *engine*.""" + from fastmcp import FastMCP + + mcp: FastMCP = FastMCP(name=name, instructions=instructions or _INSTRUCTIONS) + register_vocabulary(mcp, engine) + return mcp diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py new file mode 100644 index 000000000..e52ae7a34 --- /dev/null +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -0,0 +1,90 @@ +"""The optional fastmcp adapter on a real FastMCP server (in-process). + +Proves the framework-agnostic projection actually drives fastmcp: the curated +vocabulary registers as typed tools (engine bound out of the schema, safety -> +annotations), and an in-process client can list and call them -- offline against +the ``ConcreteEngine`` and live against a real tmux server. Skipped entirely when +the ``mcp`` extra (fastmcp) is not installed. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.fastmcp_adapter import build_server + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_adapter_registers_typed_tools() -> None: + """The curated vocabulary appears as typed tools with safety annotations.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + tools = asyncio.run(main()) + by_name = {tool.name: tool for tool in tools} + assert { + "create_session", + "create_window", + "split_pane", + "send_input", + "capture_pane", + "list_sessions", + "kill_session", + } <= set(by_name) + + # safety tier -> ToolAnnotations + assert by_name["capture_pane"].annotations.readOnlyHint is True + assert by_name["kill_session"].annotations.destructiveHint is True + assert by_name["create_session"].annotations.readOnlyHint is False + + # the engine is injected, not an agent-facing parameter + properties = by_name["create_session"].inputSchema.get("properties", {}) + assert "engine" not in properties + assert "name" in properties + + +def test_adapter_calls_tool_offline() -> None: + """Calling a tool through the in-process client returns structured output.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool("create_session", {"name": "dev"}) + + result = asyncio.run(main()) + payload = result.structured_content or {} + assert payload.get("session_id") == "$1" + assert payload.get("first_pane_id") == "%1" + + +def test_adapter_live(session: Session) -> None: + """Drive a real tmux server through fastmcp tools end to end.""" + server = session.server + mcp = build_server(SubprocessEngine.for_server(server)) + + async def main() -> str | None: + async with fastmcp.Client(mcp) as client: + created = await client.call_tool("create_session", {"name": "fastmcp-live"}) + session_id = (created.structured_content or {}).get("session_id") + await client.call_tool( + "split_pane", + {"target": session_id, "horizontal": True}, + ) + await client.call_tool("kill_session", {"target": "fastmcp-live"}) + return session_id + + session_id = asyncio.run(main()) + assert session_id is not None + assert session_id.startswith("$") + assert not server.sessions.filter(session_name="fastmcp-live") diff --git a/uv.lock b/uv.lock index eeea9e458..5dd39f1d7 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,10 @@ revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -42,6 +45,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + +[[package]] +name = "aiofile" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, +] + [[package]] name = "alabaster" version = "1.0.0" @@ -51,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.14.1" @@ -106,6 +152,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -115,6 +183,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -128,6 +214,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -137,6 +261,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -361,6 +567,98 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/e7/4b3048094559b86a800e0f0de7faf8e6d8213727cf31553ec58453f25abc/cyclopts-4.19.0.tar.gz", hash = "sha256:c7532803ab8560d4de8600769793c3de4b2dc8c3b23ec707b989d84d9bae6ff4", size = 189274, upload-time = "2026-06-22T23:58:54.176Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/e8/b84c32f35a19107b55b8ceed260de9469206464da26bd0b979db470782c3/cyclopts-4.19.0-py3-none-any.whl", hash = "sha256:a8c11adb45afae25db310122950c7639c70b56e2ce341e5b29848bf3a8ecc1d4", size = 228005, upload-time = "2026-06-22T23:58:52.495Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -370,12 +668,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -391,6 +702,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "fastmcp" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/18/46beaec18c9f86a599ae3f9cdf6677dd6b50240cfd844d18233710b47f13/fastmcp-3.4.2.tar.gz", hash = "sha256:b468722946fc467c3796a6572f7a14d93d48c014cf8fea12910245220cbbe4e1", size = 28756849, upload-time = "2026-06-06T01:30:35.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/4d/8b1ba42251160e11ca34686344572121432c23a082d56ef6bbdec5888fc1/fastmcp-3.4.2-py3-none-any.whl", hash = "sha256:c87a62b029f0c5400ada85f683629345d2466c39169f0cb853e487b2f7308c08", size = 8018, upload-time = "2026-06-06T01:30:38.118Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "joserfc" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pyperclip" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "starlette" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + [[package]] name = "gp-furo-theme" version = "0.0.1a34" @@ -451,6 +825,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/9b/1812365d4bd5b1a67aa00f18526676064ccfc6580994921f8a484b4b134f/gp_sphinx-0.0.1a34-py3-none-any.whl", hash = "sha256:4dc785ea4aa1374909ef13462a4a04bb138d6d1e57994297cf3f19bb6d02ffb5", size = 20279, upload-time = "2026-07-14T01:26:03.599Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -460,6 +843,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -478,6 +898,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -487,6 +919,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -499,6 +976,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "librt" version = "0.13.0" @@ -591,6 +1150,11 @@ name = "libtmux" version = "0.62.0" source = { editable = "." } +[package.optional-dependencies] +mcp = [ + { name = "fastmcp" }, +] + [package.dev-dependencies] coverage = [ { name = "codecov" }, @@ -640,6 +1204,8 @@ testing = [ ] [package.metadata] +requires-dist = [{ name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=3.4.2" }] +provides-extras = ["mcp"] [package.metadata.requires-dev] coverage = [ @@ -720,7 +1286,10 @@ version = "4.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "mdurl" }, @@ -815,6 +1384,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.6.1" @@ -837,6 +1431,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "mypy" version = "2.2.0" @@ -932,7 +1535,10 @@ version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "docutils" }, @@ -947,6 +1553,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -956,6 +1586,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -965,6 +1604,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -974,6 +1622,191 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -983,6 +1816,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1066,6 +1925,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1130,6 +2041,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1145,6 +2071,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, +] + [[package]] name = "roman-numerals" version = "4.1.0" @@ -1166,6 +2119,275 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + [[package]] name = "ruff" version = "0.15.20" @@ -1191,6 +2413,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "snowballstemmer" version = "3.1.1" @@ -1246,7 +2481,10 @@ version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster" }, @@ -1298,7 +2536,10 @@ version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "colorama" }, @@ -1405,7 +2646,10 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, @@ -1587,6 +2831,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/55/ab40a0d1378ee5c859590a633052cf1d0a1f8435af87558a9f7cd576601a/sphinxext_rediraffe-0.3.0-py3-none-any.whl", hash = "sha256:f4220beafa99c99177488276b8e4fcf61fbeeec4253c1e4aae841a18c475330c", size = 7194, upload-time = "2025-09-28T15:31:52.388Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -1697,6 +2954,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0" @@ -1706,6 +2975,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1945,3 +3223,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From 54f1608c08a76f93a892951d67b156652ebf1c83 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 20:49:46 -0500 Subject: [PATCH 062/223] Mcp(feat): Per-op + plan tools and a stdio server why: The fastmcp adapter only exposed the curated vocabulary -- agents had no access to the full operation set or to plan composition, and the server could not be launched on its own. what: - Register one op_ tool per operation via a dynamic-schema Tool subclass (engine + descriptor on PrivateAttr, explicit parameters schema), re-injecting target/src_target at the adapter edge; tagged per-op and hidden by default (expose_operations=True reveals them) - Register plan tools (preview_plan/execute_plan/result_schema/ build_workspace) taking serialized operations + a planner name - Add build_server flags: include_operations/expose_operations/ include_plan_tools - Make the server runnable: main()/default_server(), __main__.py, fastmcp.json, and a libtmux-engine-mcp console script - Extend adapter tests: offline per-op/plan/workspace, default-server, --help exit, live plan execution --- fastmcp.json | 11 + pyproject.toml | 5 + src/libtmux/experimental/mcp/__init__.py | 61 ++++ src/libtmux/experimental/mcp/__main__.py | 7 + .../experimental/mcp/fastmcp_adapter.py | 281 +++++++++++++++++- .../experimental/mcp/test_fastmcp_adapter.py | 138 +++++++++ 6 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 fastmcp.json create mode 100644 src/libtmux/experimental/mcp/__main__.py diff --git a/fastmcp.json b/fastmcp.json new file mode 100644 index 000000000..f4fe92417 --- /dev/null +++ b/fastmcp.json @@ -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_server" + }, + "deployment": { + "transport": "stdio" + } +} diff --git a/pyproject.toml b/pyproject.toml index 02f9cc076..f3fbf23b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,11 @@ lint = [ [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +[project.scripts] +# Experimental typed-ops MCP server (stdio). Requires the `mcp` extra; +# `main` prints an install hint and exits non-zero when fastmcp is absent. +libtmux-engine-mcp = "libtmux.experimental.mcp:main" + [project.optional-dependencies] mcp = [ "fastmcp>=3.4.2", diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index eeaf2e73a..a683f1933 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -24,6 +24,8 @@ from __future__ import annotations +import typing as t + from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor from libtmux.experimental.mcp.plan_tools import ( PlanOutcome, @@ -61,6 +63,63 @@ split_pane, ) +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from fastmcp import FastMCP + + +def default_server(*, expose_operations: bool = False) -> FastMCP: + """Build a FastMCP server over a default :class:`~..engines.SubprocessEngine`. + + A convenience factory (also the ``fastmcp.json`` entrypoint) for embedding or + deploying the server with the default tmux socket. Requires the ``mcp`` + extra (``pip install 'libtmux[mcp]'``). + """ + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + + return build_server(SubprocessEngine(), expose_operations=expose_operations) + + +def main(argv: Sequence[str] | None = None) -> None: + """Run the libtmux-engine MCP server over stdio (console-script entry). + + Wired to the ``libtmux-engine-mcp`` console script and + ``python -m libtmux.experimental.mcp``. Requires the ``mcp`` extra. + """ + import argparse + import sys + + parser = argparse.ArgumentParser( + prog="libtmux-engine-mcp", + description="Run the experimental libtmux typed-ops MCP server (stdio).", + ) + parser.add_argument("--name", default="libtmux-engine", help="server name") + parser.add_argument( + "--operations", + action="store_true", + help="expose the full per-operation tool surface (op_*)", + ) + args = parser.parse_args(argv) + + try: + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + except ImportError: + sys.stderr.write( + "libtmux-engine-mcp requires the 'mcp' extra: pip install 'libtmux[mcp]'\n", + ) + raise SystemExit(1) from None + + server = build_server( + SubprocessEngine(), + name=args.name, + expose_operations=args.operations, + ) + server.run(transport="stdio") + + __all__ = ( "Listing", "OperationToolRegistry", @@ -78,6 +137,7 @@ "capture_pane", "create_session", "create_window", + "default_server", "execute_plan", "kill_pane", "kill_session", @@ -85,6 +145,7 @@ "list_panes", "list_sessions", "list_windows", + "main", "preview_plan", "rename_session", "rename_window", diff --git a/src/libtmux/experimental/mcp/__main__.py b/src/libtmux/experimental/mcp/__main__.py new file mode 100644 index 000000000..348b9381c --- /dev/null +++ b/src/libtmux/experimental/mcp/__main__.py @@ -0,0 +1,7 @@ +"""Support ``python -m libtmux.experimental.mcp``.""" + +from __future__ import annotations + +from libtmux.experimental.mcp import main + +main() diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index c172e0788..05a5216da 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -1,9 +1,23 @@ -"""Optional fastmcp adapter -- expose the curated vocabulary on a FastMCP server. +"""Optional fastmcp adapter -- expose the typed projection on a FastMCP server. This is the thin, framework-specific edge. It requires the ``mcp`` extra (``pip install libtmux[mcp]``); fastmcp is imported lazily so the rest of -:mod:`libtmux.experimental.mcp` stays dependency-free. The agent-facing ``engine`` -is bound out of each tool's schema with :func:`functools.partial`, and the safety +:mod:`libtmux.experimental.mcp` stays dependency-free. :func:`build_server` +projects three layers of tools over one engine: + +1. **Curated vocabulary** -- the intuitive, hand-written tools + (:mod:`~libtmux.experimental.mcp.vocabulary`), always visible. +2. **Per-operation tools** -- one ``op_`` per registered operation, + auto-derived from the :class:`~..registry.OperationToolRegistry`. These carry + a precomputed JSON schema (dynamic params), so each is a :class:`fastmcp.tools. + Tool` subclass with an explicit ``parameters`` schema rather than an + introspected signature. Tagged ``per-op`` and hidden by default (the full + surface is large); ``expose_operations=True`` reveals them. +3. **Plan tools** -- :func:`preview_plan`/:func:`execute_plan`/ + :func:`result_schema`/:func:`build_workspace`, taking serialized operations so + an agent can compose and run a whole :class:`~..ops.plan.LazyPlan`. + +The agent-facing ``engine`` is bound out of each tool's schema, and the safety tier becomes the tool's ``ToolAnnotations`` + tag. Examples @@ -20,10 +34,12 @@ from __future__ import annotations +import dataclasses import inspect import typing as t from libtmux.experimental.mcp import vocabulary +from libtmux.experimental.mcp.registry import OperationToolRegistry if t.TYPE_CHECKING: from collections.abc import Callable @@ -31,6 +47,8 @@ from fastmcp import FastMCP from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.mcp.descriptor import ToolDescriptor + from libtmux.experimental.ops.plan import LazyPlan # (function, safety tier) -- every curated tool takes ``engine`` as its first arg. _VOCABULARY: tuple[tuple[Callable[..., t.Any], str], ...] = ( @@ -54,7 +72,14 @@ _INSTRUCTIONS = ( "Drive tmux through typed tools. Targets accept tmux ids (%pane, @window, " "$session), names, or 'session:window.pane'. Creating a session/window also " - "returns the new first-pane id for chaining." + "returns the new first-pane id for chaining. The curated tools cover most " + "needs; the full per-operation surface (op_*) and the plan tools " + "(preview_plan/execute_plan/result_schema/build_workspace) are available for " + "power use." +) + +_TARGET_HELP = ( + "tmux target: an id (%pane, @window, $session), a name, or 'session:window.pane'" ) @@ -113,15 +138,259 @@ def register_vocabulary(mcp: FastMCP, engine: TmuxEngine) -> None: mcp.add_tool(tool) +def _op_input_schema(descriptor: ToolDescriptor) -> dict[str, t.Any]: + """Return the per-op tool's input schema, re-adding the target params. + + The :class:`~..registry.OperationToolRegistry` omits ``target`` / + ``src_target`` from a descriptor's params (they are polymorphic + :data:`~..ops._types.Target` values, handled by + :meth:`~..descriptor.ToolDescriptor.build`), so the schema is re-completed + here -- at the framework edge -- as plain ``string`` params. Required-ness + follows the operation field's default. + """ + schema = descriptor.input_schema() + fields = { + field.name: field for field in dataclasses.fields(descriptor.operation_cls) + } + target_props: dict[str, t.Any] = {} + required = list(schema.get("required", [])) + for name in ("target", "src_target"): + field = fields.get(name) + if field is None: + continue + target_props[name] = {"type": "string", "description": _TARGET_HELP} + is_required = ( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ) + if is_required and name not in required: + required.append(name) + if target_props: + schema["properties"] = {**target_props, **schema["properties"]} + if required: + schema["required"] = required + return schema + + +def register_operations( + mcp: FastMCP, + engine: TmuxEngine, + *, + registry: OperationToolRegistry | None = None, + hidden: bool = True, +) -> None: + """Register one ``op_`` tool per registered operation. + + Each tool carries the operation's precomputed JSON schema and dispatches to + :meth:`~..descriptor.ToolDescriptor.build` + :func:`~..ops.execute.run`, + returning the serialized result. Tools are tagged ``per-op`` plus their + safety tier; when *hidden* (the default) the ``per-op`` tag is disabled so + the large surface does not clutter an agent's tool list (re-enable with + ``mcp.enable(tags={"per-op"})``). + """ + from fastmcp.tools import Tool, ToolResult + from mcp.types import ToolAnnotations + from pydantic import PrivateAttr + + from libtmux.experimental.ops import run as run_op + from libtmux.experimental.ops.serialize import result_to_dict + + class _OperationTool(Tool): + """A per-operation tool: explicit schema + dispatch to the registry.""" + + _descriptor: t.Any = PrivateAttr(default=None) + _engine: t.Any = PrivateAttr(default=None) + + async def run(self, arguments: dict[str, t.Any]) -> ToolResult: + operation = self._descriptor.build(**arguments) + result = run_op(operation, self._engine) + return ToolResult( + structured_content=result_to_dict(result), + is_error=not result.ok, + ) + + reg = registry if registry is not None else OperationToolRegistry() + for descriptor in reg.descriptors(): + annotations = ToolAnnotations( + title=descriptor.title, + readOnlyHint=descriptor.safety == "readonly", + destructiveHint=descriptor.safety == "destructive", + ) + tool = _OperationTool( + name=f"op_{descriptor.name}", + description=descriptor.description or None, + parameters=_op_input_schema(descriptor), + tags={*descriptor.tags, "per-op"}, + annotations=annotations, + ) + tool._descriptor = descriptor + tool._engine = engine + mcp.add_tool(tool) + if hidden: + mcp.disable(tags={"per-op"}) + + +def register_plan_tools( + mcp: FastMCP, + engine: TmuxEngine, + *, + registry: OperationToolRegistry | None = None, +) -> None: + """Register the plan-tier tools (compose + run serialized :class:`LazyPlan`s). + + ``preview_plan`` renders without executing; ``execute_plan`` runs a + serialized plan (forward refs resolved via bindings); ``result_schema`` + reports what a kind returns; ``build_workspace`` builds the Declarative tier + in one call. All take JSON-serializable inputs (operation dicts from + :func:`~..ops.serialize.operation_to_dict`). + """ + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + from libtmux.experimental.mcp import plan_tools as _plan + from libtmux.experimental.ops import LazyPlan + from libtmux.experimental.ops.planner import ( + FoldingPlanner, + MarkedPlanner, + Planner, + SequentialPlanner, + ) + from libtmux.experimental.ops.serialize import operation_from_dict + + reg = registry if registry is not None else OperationToolRegistry() + planners: dict[str, type[Planner]] = { + "sequential": SequentialPlanner, + "folding": FoldingPlanner, + "marked": MarkedPlanner, + } + + def _plan_from_dicts(operations: list[dict[str, t.Any]]) -> LazyPlan: + plan = LazyPlan() + for data in operations: + plan.add(operation_from_dict(data)) + return plan + + def preview_plan( + operations: list[dict[str, t.Any]], + version: str | None = None, + ) -> dict[str, t.Any]: + """Render a serialized plan without executing it (refs render as null).""" + preview = _plan.preview_plan(_plan_from_dicts(operations), version=version) + return { + "ok": preview.ok, + "operations": preview.operations, + "argv": [list(item) if item is not None else None for item in preview.argv], + } + + def execute_plan( + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + chosen = planners.get(planner) + if chosen is None: + msg = f"unknown planner {planner!r}; choose from {sorted(planners)}" + raise ValueError(msg) + outcome = _plan.execute_plan( + _plan_from_dicts(operations), + engine, + version=version, + planner=chosen(), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + def result_schema(kind: str) -> dict[str, t.Any]: + """Report what an operation kind returns, for planning forward refs.""" + schema = _plan.result_schema(reg, kind) + return { + "kind": schema.kind, + "result_type": schema.result_type, + "schema": schema.schema, + "binding_fields": schema.binding_fields, + } + + def build_workspace( + spec: dict[str, t.Any], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build a declarative workspace (the Declarative tier) in one call.""" + outcome = _plan.build_workspace( + spec, + engine, + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools: tuple[tuple[Callable[..., t.Any], str], ...] = ( + (preview_plan, "readonly"), + (result_schema, "readonly"), + (execute_plan, "mutating"), + (build_workspace, "mutating"), + ) + for fn, safety in tools: + annotations = ToolAnnotations( + title=fn.__name__, + readOnlyHint=safety == "readonly", + destructiveHint=False, + ) + tool = FunctionTool.from_function( + fn, + name=fn.__name__, + description=_summary(fn.__doc__), + tags={"plan", safety}, + annotations=annotations, + ) + mcp.add_tool(tool) + + def build_server( engine: TmuxEngine, *, - name: str = "libtmux", + name: str = "libtmux-engine", instructions: str | None = None, + include_operations: bool = True, + expose_operations: bool = False, + include_plan_tools: bool = True, ) -> FastMCP: - """Build a FastMCP server exposing the curated vocabulary over *engine*.""" + """Build a FastMCP server exposing the typed tool surface over *engine*. + + Parameters + ---------- + engine + The :class:`~..engines.base.TmuxEngine` every tool runs against. + name, instructions + Server identity (``instructions`` defaults to a built-in primer). + include_operations + Register the auto-derived ``op_`` per-operation tools. + expose_operations + Reveal those per-operation tools by default (otherwise they are + registered but hidden behind the ``per-op`` tag). + include_plan_tools + Register the plan-tier tools. + """ from fastmcp import FastMCP mcp: FastMCP = FastMCP(name=name, instructions=instructions or _INSTRUCTIONS) + registry = OperationToolRegistry() register_vocabulary(mcp, engine) + if include_operations: + register_operations( + mcp, + engine, + registry=registry, + hidden=not expose_operations, + ) + if include_plan_tools: + register_plan_tools(mcp, engine, registry=registry) return mcp diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py index e52ae7a34..80dc91278 100644 --- a/tests/experimental/mcp/test_fastmcp_adapter.py +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -16,6 +16,8 @@ from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server +from libtmux.experimental.ops import NewSession +from libtmux.experimental.ops.serialize import operation_to_dict fastmcp = pytest.importorskip("fastmcp") @@ -88,3 +90,139 @@ async def main() -> str | None: assert session_id is not None assert session_id.startswith("$") assert not server.sessions.filter(session_name="fastmcp-live") + + +def test_adapter_operations_hidden_by_default() -> None: + """Per-operation tools are registered but hidden; plan tools stay visible.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + names = {tool.name for tool in asyncio.run(main())} + assert not any(name.startswith("op_") for name in names) + assert { + "preview_plan", + "execute_plan", + "result_schema", + "build_workspace", + } <= names + + +def test_adapter_exposes_per_op_tools() -> None: + """``expose_operations`` reveals one typed ``op_`` per operation.""" + server = build_server(ConcreteEngine(), expose_operations=True) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + by_name = {tool.name: tool for tool in asyncio.run(main())} + assert "op_split_window" in by_name + assert "op_new_session" in by_name + + # the target the registry omits is re-injected into the per-op schema + properties = by_name["op_split_window"].inputSchema.get("properties", {}) + assert "target" in properties + assert "horizontal" in properties + + # safety tier -> annotations + assert by_name["op_kill_session"].annotations.destructiveHint is True + assert by_name["op_capture_pane"].annotations.readOnlyHint is True + + +def test_adapter_per_op_call_offline() -> None: + """A per-op tool builds + runs its operation, returning the serialized result.""" + server = build_server(ConcreteEngine(), expose_operations=True) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool("op_new_session", {"session_name": "raw"}) + + payload = asyncio.run(main()).structured_content or {} + assert payload["operation"]["kind"] == "new_session" + assert payload["new_id"] == "$1" + + +def test_adapter_plan_tools_offline() -> None: + """preview/execute/result_schema drive a serialized plan with forward refs.""" + server = build_server(ConcreteEngine()) + operations = [operation_to_dict(NewSession(session_name="dev", capture_panes=True))] + + async def main() -> tuple[t.Any, t.Any, t.Any]: + async with fastmcp.Client(server) as client: + preview = await client.call_tool("preview_plan", {"operations": operations}) + outcome = await client.call_tool("execute_plan", {"operations": operations}) + schema = await client.call_tool("result_schema", {"kind": "new_session"}) + return preview, outcome, schema + + preview, outcome, schema = asyncio.run(main()) + assert preview.structured_content["ok"] is True + assert outcome.structured_content["ok"] is True + # the new session's captured sub-ids surface as forward-ref bindings + assert outcome.structured_content["bindings"]["0"] == "$1" + assert outcome.structured_content["bindings"]["0:pane"] == "%1" + assert "first_pane_id" in schema.structured_content["binding_fields"] + + +def test_adapter_build_workspace_offline() -> None: + """The workspace tool builds a declarative spec in one call (preflight off).""" + server = build_server(ConcreteEngine()) + spec = { + "session_name": "ws", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + } + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool( + "build_workspace", + {"spec": spec, "preflight": False}, + ) + + payload = asyncio.run(main()).structured_content or {} + assert payload["ok"] is True + + +def test_default_server_builds() -> None: + """The packaged ``default_server`` factory exposes the curated + plan tools.""" + from libtmux.experimental.mcp import default_server + + server = default_server() + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + names = {tool.name for tool in asyncio.run(main())} + assert "create_session" in names + assert "execute_plan" in names + + +def test_main_help_exits() -> None: + """The console-script entry parses ``--help`` and exits cleanly.""" + from libtmux.experimental.mcp import main + + with pytest.raises(SystemExit) as excinfo: + main(["--help"]) + assert excinfo.value.code == 0 + + +def test_adapter_plan_live(session: Session) -> None: + """Execute a serialized plan over real tmux through the execute_plan tool.""" + server = session.server + mcp = build_server(SubprocessEngine.for_server(server)) + operations = [ + operation_to_dict(NewSession(session_name="plan-live", capture_panes=True)), + ] + + async def main() -> t.Any: + async with fastmcp.Client(mcp) as client: + return await client.call_tool("execute_plan", {"operations": operations}) + + outcome = asyncio.run(main()).structured_content + assert outcome["ok"] is True + assert outcome["bindings"]["0"].startswith("$") + assert server.sessions.filter(session_name="plan-live") + server.cmd("kill-session", "-t", "plan-live") From 36f8d51600a5de5131393a0979f59b2ae258da51 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 21:43:13 -0500 Subject: [PATCH 063/223] Mcp(feat): Port mcp_swap config-swap dev script why: To test and dogfood the MCP server in local agent CLIs, we need a way to point Claude/Codex/Cursor/Gemini at this checkout instead of a pinned release. what: - Port scripts/mcp_swap.py from libtmux-mcp (PEP 723 uv-script, tomlkit): detect/status/use-local/revert with timestamped backups, dry-run, and Claude user/project scopes - Derive the server slug from the [project.scripts] entry (libtmux-engine-mcp -> libtmux-engine) instead of project.name, so it stays distinct from a sibling libtmux server; a strict generalization (libtmux-mcp still resolves to libtmux) - Namespace the swap state dir libtmux-engine-mcp-dev - Add tests: console-script registration (always-on) + slug/local-spec derivation (tomlkit-gated) --- scripts/mcp_swap.py | 1083 ++++++++++++++++++++++++++++++++++++++++ tests/test_mcp_swap.py | 61 +++ 2 files changed, 1144 insertions(+) create mode 100644 scripts/mcp_swap.py create mode 100644 tests/test_mcp_swap.py diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py new file mode 100644 index 000000000..6e68b6d1c --- /dev/null +++ b/scripts/mcp_swap.py @@ -0,0 +1,1083 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["tomlkit>=0.13"] +# /// +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini CLIs. + +Use when you want every installed agent CLI to run a local checkout of an +MCP server (editable) instead of a pinned release. ``use-local`` rewrites +each CLI's config to invoke the checkout via ``uv --directory run +``; ``revert`` restores from the timestamped backup the swap wrote. + +Defaults are derived from the current repo's ``pyproject.toml``: + +- entry command = first key of ``[project.scripts]`` +- server name = that entry with a trailing ``-mcp`` stripped + (``libtmux-engine-mcp`` -> ``libtmux-engine``), falling back to + ``project.name`` when the entry has no ``-mcp`` suffix. Deriving the + slug from the entry (not ``project.name``) keeps this repo's server + key distinct from a sibling package whose ``project.name`` differs + from its console-script name. + +Examples +-------- +```console +$ uv run scripts/mcp_swap.py detect +$ uv run scripts/mcp_swap.py status +$ uv run scripts/mcp_swap.py use-local --dry-run +$ uv run scripts/mcp_swap.py use-local +$ uv run scripts/mcp_swap.py revert +``` + +Scope +----- +This script is best-effort and intentionally narrow: + +- **Global configs only.** Writes to ``~/.cursor/mcp.json``, + ``~/.claude.json``, ``~/.codex/config.toml``, and + ``~/.gemini/settings.json``. Workspace / project-local configs + (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, + per-project ``projects..mcpServers`` entries inside + ``~/.claude.json`` *are* recognised for Claude only) are NOT + walked — workspace files for Cursor/Gemini are silently ignored. + When workspace precedence matters, run the CLI's own + ``cursor mcp add ...`` / ``gemini mcp add ...`` directly. + +- **Claude scope.** ``use-local`` and ``revert`` accept + ``--scope {user,project}``. The default ``project`` writes the + per-project entry under ``projects[].mcpServers`` — + only the current repo's directory sees the swap, matching + pre-flag behaviour. ``--scope user`` writes Claude's top-level + ``mcpServers`` fallback so every project that has no per-project + override picks up the swap; useful when QA-ing a branch across + many directories. Codex, Cursor, and Gemini have no per-project + layer in their config files; the flag is silently coerced to + ``user`` for them. Both Claude scopes can coexist with + independent backups; full ``revert`` unwinds in LIFO order. +- **Simple binary detection.** Probing is ``shutil.which()`` + plus ``.exists()``. Custom install locations + (Homebrew, npm prefixes, ``~/.npm-global/bin``, + ``~/.claude/local/claude``, ``~/.gemini/local/gemini``) are picked + up only if the binary is on ``PATH``. FastMCP's installer probes + these locations directly; this script does not. +- **Single config shape per CLI.** No fallback paths, no merge of + multiple sources. If your setup deviates from the defaults above, + use the CLI's native ``mcp`` subcommand instead. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import json +import os +import pathlib +import shutil +import sys +import tempfile +import time +import typing as t + +import tomlkit +import tomlkit.items + +CLIName = t.Literal["claude", "codex", "cursor", "gemini"] +ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini") + +#: Claude config scope: ``"user"`` targets the user/system-level top-level +#: ``mcpServers`` fallback that applies to every project without its own +#: override; ``"project"`` targets the project-level per-project +#: ``projects..mcpServers`` node. Codex / Cursor / Gemini have no +#: per-project scope in their config files, so for those CLIs the scope +#: is always normalised to ``"user"`` regardless of what was passed. +Scope = t.Literal["user", "project"] +ALL_SCOPES: tuple[Scope, ...] = ("user", "project") + + +def _normalize_scope(cli: CLIName, scope: Scope | None) -> Scope: + """Coerce ``scope`` to the value that actually applies to ``cli``. + + Non-Claude CLIs have no per-project config layer — every write to + them is necessarily user-level — so the flag is silently coerced to + ``"user"`` for those. For Claude, ``None`` defaults to ``"project"`` + to preserve pre-flag behaviour where the script always wrote the + per-project entry. + """ + if cli != "claude": + return "user" + return scope if scope is not None else "project" + + +def _state_key(cli: CLIName, scope: Scope) -> str: + """Compose the ``cli:scope`` key used inside the state file.""" + return f"{cli}:{scope}" + + +def _parse_state_key(key: str) -> tuple[CLIName, Scope] | None: + """Decode a ``cli:scope`` state key, returning ``None`` for malformed input. + + The script declares no compatibility contract for its state file — + schema is internal — so this only accepts the canonical + ``f"{cli}:{scope}"`` form. Hand-edited or unrecognised keys return + ``None`` so ``load_state`` can drop them without crashing. + """ + if ":" not in key: + return None + cli_str, _, scope_str = key.partition(":") + if cli_str in ALL_CLIS and scope_str in ALL_SCOPES: + return cli_str, scope_str + return None + + +def _parse_state_entry(v: dict[str, t.Any]) -> SwapEntry | None: + """Build a :class:`SwapEntry` from a raw state-file dict, or ``None``. + + Validates at the trust boundary so a hand-edited ``state.json`` can't + crash later code paths — particularly :func:`cmd_revert`'s LIFO sort, + which compares ``SwapEntry.seq_no`` and would raise ``TypeError`` on a + mixed ``int``/``str`` ordering. ``seq_no`` is coerced via ``int()``; + any ``KeyError`` (missing required field), ``ValueError`` (non-numeric + string), or ``TypeError`` (wrong shape, extra keys for the dataclass) + drops the entry silently. Same drop-on-malformed posture as + :func:`_parse_state_key`. + + Mirrors CPython's ``Lib/sched.py`` discipline: validate at the + counter's *origin* (``enterabs`` for sched, ``load_state`` here), not + at sort time. State-file schema is internal — no compatibility + contract — so silent drop is the right failure mode. + """ + try: + v = {**v, "seq_no": int(v["seq_no"])} + return SwapEntry(**v) + except (KeyError, TypeError, ValueError): + return None + + +def _xdg_state_home() -> pathlib.Path: + """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. + + Defaults to ``~/.local/state`` when the env var is unset or empty. + State is the right XDG bucket here (vs. cache / config / data): the + file is machine-written, must persist across runs so ``revert`` can + locate the right backup, but is not safely deletable like cache nor + user-edited like config. + """ + env = os.environ.get("XDG_STATE_HOME") + if env: + return pathlib.Path(env) + return pathlib.Path.home() / ".local" / "state" + + +# ``-dev`` suffix in the namespace makes it loud that this is dev-only +# tooling state, distinct from the runtime ``libtmux`` package and from +# any sibling ``libtmux-mcp-dev`` swap state. +STATE_DIR = _xdg_state_home() / "libtmux-engine-mcp-dev" / "swap" +STATE_FILE = STATE_DIR / "state.json" + +BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class CLIInfo: + """Static descriptor for a CLI's config file and discovery heuristics.""" + + name: CLIName + binary: str + config_path: pathlib.Path + fmt: t.Literal["json", "toml"] + + +CLIS: dict[CLIName, CLIInfo] = { + "claude": CLIInfo( + name="claude", + binary="claude", + config_path=pathlib.Path.home() / ".claude.json", + fmt="json", + ), + "codex": CLIInfo( + name="codex", + binary="codex", + config_path=pathlib.Path.home() / ".codex" / "config.toml", + fmt="toml", + ), + "cursor": CLIInfo( + name="cursor", + binary="cursor-agent", + config_path=pathlib.Path.home() / ".cursor" / "mcp.json", + fmt="json", + ), + "gemini": CLIInfo( + name="gemini", + binary="gemini", + config_path=pathlib.Path.home() / ".gemini" / "settings.json", + fmt="json", + ), +} + + +@dataclasses.dataclass +class McpServerSpec: + """The portable shape shared across CLI configs.""" + + command: str + args: list[str] = dataclasses.field(default_factory=list) + env: dict[str, str] = dataclasses.field(default_factory=dict) + + def to_json_dict(self, *, include_stdio_type: bool = False) -> dict[str, t.Any]: + """Serialize to the JSON shape (Claude-extended when ``include_stdio_type``).""" + # Claude's format always includes ``type`` and ``env`` (even when empty); + # Cursor/Gemini omit both. include_stdio_type selects Claude shape. + if include_stdio_type: + return { + "type": "stdio", + "command": self.command, + "args": list(self.args), + "env": dict(self.env), + } + out: dict[str, t.Any] = {"command": self.command, "args": list(self.args)} + if self.env: + out["env"] = dict(self.env) + return out + + def is_local_uv_directory(self) -> bool: + """Return True for a ``uv --directory run `` shape.""" + return ( + self.command == "uv" and "--directory" in self.args and "run" in self.args + ) + + def local_repo_path(self) -> pathlib.Path | None: + """Extract the ``--directory`` argument, if any.""" + try: + i = self.args.index("--directory") + except ValueError: + return None + if i + 1 >= len(self.args): + return None + return pathlib.Path(self.args[i + 1]) + + +@dataclasses.dataclass +class SwapEntry: + """One CLI's bookkeeping for a swap, written to the state file.""" + + config_path: str + backup_path: str + server: str + action: t.Literal["replaced", "added"] + #: ``YYYYMMDDHHMMSS`` registration timestamp, human-readable for + #: anyone inspecting ``state.json`` directly. Sort order is enforced + #: separately via :attr:`seq_no` so this field stays purely + #: descriptive. + swapped_at: str + #: Monotonic registration counter — the primary LIFO sort key for + #: ``cmd_revert``. ``cmd_use_local`` computes the next value as + #: ``max(existing seq_nos, default=-1) + 1`` so it strictly + #: increases per swap regardless of wall-clock collisions or dict + #: iteration order. Same explicit-counter pattern CPython's + #: ``Lib/sched.py`` uses to break ties on ``Event(time, priority, + #: sequence, …)``. + seq_no: int + + +# --------------------------------------------------------------------------- +# Config IO — per format +# --------------------------------------------------------------------------- + + +def load_config(info: CLIInfo) -> t.Any: + """Parse a CLI's config file (JSON or TOML) into an editable structure.""" + raw = info.config_path.read_bytes() + if info.fmt == "json": + return json.loads(raw) + return tomlkit.parse(raw.decode()) + + +def dump_config_bytes(info: CLIInfo, config: t.Any) -> bytes: + """Serialize an edited config back to bytes in its original format.""" + if info.fmt == "json": + return (json.dumps(config, indent=2) + "\n").encode() + return tomlkit.dumps(config).encode() + + +def atomic_write(path: pathlib.Path, data: bytes) -> None: + """Write bytes to ``path`` via tempfile + ``os.replace`` to avoid partial writes.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + tmp = pathlib.Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + tmp.replace(path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +# --------------------------------------------------------------------------- +# Per-CLI get / set / delete (the only CLI-specific logic) +# --------------------------------------------------------------------------- + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[True], +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[False], +) -> dict[str, t.Any] | None: ... + + +def _claude_project_node( + config: dict[str, t.Any], repo: pathlib.Path, *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the ``projects.`` node Claude keys per-project. + + With ``create=True``, the node is unconditionally created if missing + and the return type is statically narrowed to ``dict[str, t.Any]``; + callers can drop runtime ``assert node is not None`` defensiveness. + With ``create=False``, the absence of the node is a real return value + and the type stays ``dict[str, t.Any] | None``. + + Raises ``RuntimeError`` if Claude's config layout is not the + expected ``projects..mcpServers`` mapping shape — the layout + is undocumented Claude Code internal state, so a clear error before + the atomic write beats a silent partial mutation that the backup + defense would be asked to recover from. + """ + key = str(repo.resolve()) + projects_node = config.get("projects") + if projects_node is not None and not isinstance(projects_node, dict): + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects' to be a mapping but got " + f"{type(projects_node).__name__}" + ) + raise RuntimeError(msg) + projects = ( + config.setdefault("projects", {}) if create else config.get("projects", {}) + ) + raw_node = projects.get(key) + node: dict[str, t.Any] | None = None + if isinstance(raw_node, dict): + node = raw_node + elif raw_node is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects[{key!r}]' to be a mapping but got " + f"{type(raw_node).__name__}" + ) + raise RuntimeError(msg) + if node is None and create: + node = {"allowedTools": [], "mcpContextUris": [], "mcpServers": {}, "env": {}} + projects[key] = node + return node + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[True] +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[False] +) -> dict[str, t.Any] | None: ... + + +def _claude_user_servers( + config: dict[str, t.Any], *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the top-level ``mcpServers`` dict — Claude user scope. + + Mirrors :func:`_claude_project_node` for the user-scope path so the + shape guard is centralised once and reused across read / write / + delete instead of duplicated at each call site (or worse, missing + on read and delete the way the inline write-side guard left them). + Same reasoning applies as for the project-scope helper: Claude's + config shape is undocumented internal state, so a clear + ``RuntimeError`` before the atomic write beats an opaque + ``AttributeError`` from ``.setdefault()`` on a non-dict. + + With ``create=True`` the dict is initialised when missing and the + return type narrows to ``dict[str, t.Any]``. With ``create=False`` + a missing key returns ``None``. + """ + raw = config.get("mcpServers") + existing: dict[str, t.Any] | None = None + if isinstance(raw, dict): + existing = raw + elif raw is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'mcpServers' to be a mapping but got " + f"{type(raw).__name__}" + ) + raise RuntimeError(msg) + if existing is None and create: + existing = {} + config["mcpServers"] = existing + return existing + + +def get_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> McpServerSpec | None: + """Fetch the MCP server entry for ``name`` from a CLI's config, if present. + + ``scope`` only affects Claude (see :data:`Scope` for the layered shape + of ``~/.claude.json``); for Codex / Cursor / Gemini the parameter is + accepted-but-ignored because their config has no per-project layer. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + entry = servers.get(name) if servers else None + else: + node = _claude_project_node(config, repo, create=False) + if not node: + return None + entry = node.get("mcpServers", {}).get(name) + elif cli in ("cursor", "gemini"): + entry = config.get("mcpServers", {}).get(name) + else: # cli == "codex" + entry = config.get("mcp_servers", {}).get(name) + if entry is None: + return None + return _spec_from_entry(entry, fmt=CLIS[cli].fmt) + + +def set_server( + cli: CLIName, + config: t.Any, + name: str, + spec: McpServerSpec, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> t.Literal["replaced", "added"]: + """Write ``spec`` under ``name`` in a CLI's config, returning replaced/added. + + ``scope == "user"`` for Claude writes the top-level ``mcpServers`` + fallback used by every project that has no per-project override; + ``"project"`` (the default, preserving pre-flag behaviour) writes + under ``projects[abs(repo)].mcpServers``. The parameter is silently + ignored for non-Claude CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=True) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + node = _claude_project_node(config, repo, create=True) + servers = node.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + if cli in ("cursor", "gemini"): + servers = config.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict() + return "replaced" if had else "added" + if cli == "codex": + # tomlkit: top-level tables are accessed via dict protocol too. + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + mcp_servers = tomlkit.table() + config["mcp_servers"] = mcp_servers + had = name in mcp_servers + table = tomlkit.table() + table["command"] = spec.command + table["args"] = list(spec.args) + if spec.env: + env_tbl = tomlkit.table() + for k, v in spec.env.items(): + env_tbl[k] = v + table["env"] = env_tbl + mcp_servers[name] = table + return "replaced" if had else "added" + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def delete_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> bool: + """Remove the entry for ``name`` from a CLI's config; return whether it existed. + + See :func:`set_server` for the meaning of ``scope`` — the parameter + is honoured for Claude and ignored for the other CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + if servers is not None and name in servers: + del servers[name] + return True + return False + node = _claude_project_node(config, repo, create=False) + if not node: + return False + servers = node.get("mcpServers", {}) + return servers.pop(name, None) is not None + if cli in ("cursor", "gemini"): + return config.get("mcpServers", {}).pop(name, None) is not None + if cli == "codex": + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + return False + if name in mcp_servers: + del mcp_servers[name] + return True + return False + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def _spec_from_entry(entry: t.Any, *, fmt: t.Literal["json", "toml"]) -> McpServerSpec: + """Convert a raw config entry (dict or tomlkit Table) into an McpServerSpec.""" + # tomlkit items quack like dicts/lists; coerce to plain Python for our spec. + if fmt == "toml": + entry = ( + tomlkit.items.Table.unwrap(entry) + if isinstance(entry, tomlkit.items.Table) + else dict(entry) + ) + command = str(entry.get("command", "")) + raw_args = entry.get("args", []) + args = [str(a) for a in raw_args] if raw_args else [] + raw_env = entry.get("env") or {} + env = {str(k): str(v) for k, v in dict(raw_env).items()} + return McpServerSpec(command=command, args=args, env=env) + + +# --------------------------------------------------------------------------- +# Repo metadata +# --------------------------------------------------------------------------- + + +def resolve_repo_meta(repo: pathlib.Path) -> tuple[str, str]: + """Derive (server_name, entry_command) from the repo's pyproject.toml. + + The server name is the registration slug used as the config-file key + (``mcpServers.`` in JSON, ``[mcp_servers.]`` in TOML). + Default: the first ``[project.scripts]`` entry with a trailing + ``-mcp`` stripped (``libtmux-engine-mcp`` → ``libtmux-engine``), + falling back to ``project.name`` when the entry has no ``-mcp`` + suffix. Deriving the slug from the entry rather than ``project.name`` + keeps this repo's server key (``libtmux-engine``) distinct from a + sibling package whose ``project.name`` is ``libtmux`` — both can be + registered side by side. Pass ``--server `` to override. + """ + pyproject = repo / "pyproject.toml" + doc = tomlkit.parse(pyproject.read_text()) + project = doc.get("project") + if project is None: + msg = f"{pyproject} has no [project] table" + raise RuntimeError(msg) + scripts = project.get("scripts") or {} + if not scripts: + msg = f"{pyproject} has no [project.scripts] — cannot derive entry" + raise RuntimeError(msg) + entry = next(iter(scripts)) + server = entry[: -len("-mcp")] if entry.endswith("-mcp") else str(project["name"]) + return server, entry + + +def build_local_spec(repo: pathlib.Path, entry: str) -> McpServerSpec: + """Build the ``uv --directory run `` spec used by ``use-local``.""" + return McpServerSpec( + command="uv", + args=["--directory", str(repo.resolve()), "run", entry], + ) + + +# --------------------------------------------------------------------------- +# State file +# --------------------------------------------------------------------------- + + +def load_state() -> dict[tuple[CLIName, Scope], SwapEntry]: + """Read the swap-state file, returning an empty mapping when absent. + + The state file's schema is internal — no compatibility contract — + so this loader assumes a single canonical shape. Malformed keys + (those that don't parse as ``cli:scope``) and entries with a + non-coercible ``seq_no`` or missing required fields are dropped + silently so a hand-edited file cannot crash the script. + """ + if not STATE_FILE.exists(): + return {} + raw = json.loads(STATE_FILE.read_text()) + entries = raw.get("entries", {}) + out: dict[tuple[CLIName, Scope], SwapEntry] = {} + for k, v in entries.items(): + parsed = _parse_state_key(k) + if parsed is None: + continue + entry = _parse_state_entry(v) + if entry is None: + continue + out[parsed] = entry + return out + + +def save_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: + """Write the swap-state file atomically.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "entries": { + _state_key(cli, scope): dataclasses.asdict(v) + for (cli, scope), v in entries.items() + }, + } + atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) + + +def clear_state(keys: t.Iterable[tuple[CLIName, Scope]]) -> None: + """Remove the given ``(cli, scope)`` keys; delete the file if empty.""" + current = load_state() + for key in keys: + current.pop(key, None) + if current: + save_state(current) + elif STATE_FILE.exists(): + STATE_FILE.unlink() + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Presence: + """Detection outcome for a CLI: binary on PATH and config file present.""" + + cli: CLIName + binary_found: bool + config_found: bool + + @property + def present(self) -> bool: + """Return True only when both the binary and the config file were found.""" + return self.binary_found and self.config_found + + +def detect_clis() -> list[Presence]: + """Probe all supported CLIs and return their detection results.""" + return [ + Presence( + cli=info.name, + binary_found=shutil.which(info.binary) is not None, + config_found=info.config_path.exists(), + ) + for info in CLIS.values() + ] + + +def present_clis() -> list[CLIName]: + """Return the list of CLIs that have both a binary and a config present.""" + return [p.cli for p in detect_clis() if p.present] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +def cmd_detect(args: argparse.Namespace) -> int: + """Print detection results for every supported CLI.""" + for p in detect_clis(): + flag = "yes" if p.present else " no" + extra = [] + if not p.binary_found: + extra.append("binary missing") + if not p.config_found: + extra.append(f"config missing: {CLIS[p.cli].config_path}") + suffix = f" ({', '.join(extra)})" if extra else "" + print(f" [{flag}] {p.cli:<7}{suffix}") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + """Print the current MCP server entry per detected CLI. + + For Claude, prints separate lines for the user-level fallback + (``[claude:user]``) and the per-project override + (``[claude:project]``) when both exist; if only one exists, only + that line shows. ``args.scope`` (when set) restricts Claude output + to the matching layer only. Other CLIs print a single line as + ``[]`` since their config has no scope concept and ignore + ``args.scope``. + """ + repo = pathlib.Path(args.repo).resolve() + server = args.server or resolve_repo_meta(repo)[0] + scope_filter: Scope | None = args.scope + for cli in args.cli or present_clis(): + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{cli}] (no config at {info.config_path})") + continue + # Wrap the read + shape-guarded queries in try/except RuntimeError + # so a malformed Claude config surfaces as a clean per-CLI error + # instead of aborting status output for the rest of the CLIs. + try: + config = load_config(info) + if cli == "claude": + # Lazy reads: skip the get_server call entirely for the + # filtered-out scope so a malformed projects node doesn't + # raise when the user only asked about user scope. + user_spec = ( + get_server(cli, config, server, repo, scope="user") + if scope_filter in (None, "user") + else None + ) + project_spec = ( + get_server(cli, config, server, repo, scope="project") + if scope_filter in (None, "project") + else None + ) + shown = False + if user_spec is not None: + tag = _describe_spec(user_spec, repo) + print( + f"[claude:user] {server} = {user_spec.command} " + f"{' '.join(user_spec.args)} ({tag})" + ) + shown = True + if project_spec is not None: + tag = _describe_spec(project_spec, repo) + print( + f"[claude:project] {server} = {project_spec.command} " + f"{' '.join(project_spec.args)} ({tag})" + ) + shown = True + if not shown: + label = f"claude:{scope_filter}" if scope_filter else "claude" + print(f"[{label}] no entry for {server!r}") + else: + spec = get_server(cli, config, server, repo) + if spec is None: + print(f"[{cli}] no entry for {server!r}") + continue + tag = _describe_spec(spec, repo) + print( + f"[{cli}] {server} = {spec.command} {' '.join(spec.args)} ({tag})" + ) + except RuntimeError as exc: + print(f"[{cli}] {exc}", file=sys.stderr) + continue + return 0 + + +def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: + """Return a short label classifying a spec (local/pypi-pin/other).""" + if spec.is_local_uv_directory(): + local = spec.local_repo_path() + if local and local.resolve() == repo.resolve(): + return "local: this repo" + return f"local: {local}" + if spec.command == "uvx": + pinned = next((a for a in spec.args if "==" in a or "@" in a), None) + return f"pypi pin: {pinned}" if pinned else "pypi (unpinned)" + return "other" + + +def cmd_use_local(args: argparse.Namespace) -> int: + """Rewrite each target CLI's config to run the repo's checkout via ``uv``. + + The optional ``--scope`` flag selects Claude's user-level fallback + vs. per-project override; see :data:`Scope`. The flag is silently + coerced to ``"user"`` for non-Claude CLIs by :func:`_normalize_scope`. + """ + repo = pathlib.Path(args.repo).resolve() + server, default_entry = resolve_repo_meta(repo) + server = args.server or server + entry = args.entry or default_entry + spec = build_local_spec(repo, entry) + + targets = args.cli or present_clis() + if not targets: + print("no CLIs detected — nothing to do", file=sys.stderr) + return 1 + + ts = time.strftime("%Y%m%d%H%M%S") + state = load_state() + had_error = 0 + for cli in targets: + scope = _normalize_scope(cli, args.scope) + label = f"{cli}:{scope}" if cli == "claude" else cli + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{label}] skip — config not found at {info.config_path}") + continue + # Wrap the read + shape-guarded mutation in try/except RuntimeError + # so a malformed Claude config (top-level mcpServers / projects not a + # mapping) surfaces as a clean per-CLI error instead of an uncaught + # traceback. Same per-CLI continuation pattern the inner write-failure + # handler below uses. + try: + original_bytes = info.config_path.read_bytes() + config = load_config(info) + current = get_server(cli, config, server, repo, scope=scope) + if ( + current + and current.is_local_uv_directory() + and current.local_repo_path() == repo + ): + print(f"[{label}] already local (this repo) — no change") + continue + # Preserve the existing entry's env on replacement. ``build_local_spec`` + # writes an empty env, so without this merge a swap would silently drop + # client-side settings (LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev + # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on + # the read side. + cli_spec = ( + dataclasses.replace(spec, env={**current.env}) if current else spec + ) + action = set_server(cli, config, server, cli_spec, repo, scope=scope) + new_bytes = dump_config_bytes(info, config) + except RuntimeError as exc: + print(f"[{label}] {exc}", file=sys.stderr) + had_error = 1 + continue + + if args.dry_run: + print(f"--- {info.config_path} (current)") + print(f"+++ {info.config_path} (proposed)") + diff = difflib.unified_diff( + original_bytes.decode(errors="replace").splitlines(keepends=True), + new_bytes.decode(errors="replace").splitlines(keepends=True), + lineterm="", + ) + sys.stdout.writelines(diff) + continue + + # Claude is the only CLI where two swaps (different scopes) can + # touch the same config file in one second; embed the scope so + # the second backup doesn't overwrite the first. Non-Claude + # backup filenames carry no scope suffix. + backup_suffix = f"{BACKUP_SUFFIX_PREFIX}{ts}" + if cli == "claude": + backup_suffix += f"-{scope}" + backup_path = info.config_path.with_suffix( + info.config_path.suffix + backup_suffix + ) + backup_path.write_bytes(original_bytes) + try: + atomic_write(info.config_path, new_bytes) + _revalidate(info) + except Exception as exc: + atomic_write(info.config_path, original_bytes) + print( + f"[{label}] write failed ({exc}); backup at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + next_seq = max((e.seq_no for e in state.values()), default=-1) + 1 + state[(cli, scope)] = SwapEntry( + config_path=str(info.config_path), + backup_path=str(backup_path), + server=server, + action=action, + swapped_at=ts, + seq_no=next_seq, + ) + print(f"[{label}] {action}; backup: {backup_path}") + + if not args.dry_run: + save_state(state) + return had_error + + +def _revalidate(info: CLIInfo) -> None: + """Re-parse the file after writing; raise on failure.""" + load_config(info) + + +def cmd_revert(args: argparse.Namespace) -> int: + """Restore each target CLI's config from the backup recorded in the state file. + + Without ``--scope``, every recorded entry for the targeted CLIs is + reverted (so a Claude install that has both user-scope and + project-scope swaps gets both restored). With ``--scope``, only + the matching scope is reverted; the parameter is silently coerced + to ``"user"`` for non-Claude CLIs. + """ + state = load_state() + # Without --cli, revert every CLI that has any recorded swap. + targets = list(args.cli) if args.cli else list({cli for cli, _scope in state}) + if not targets: + print("no recorded swaps — nothing to revert", file=sys.stderr) + return 1 + + reverted: list[tuple[CLIName, Scope]] = [] + for cli in targets: + if args.scope is not None: + wanted_scopes: tuple[Scope, ...] = (_normalize_scope(cli, args.scope),) + else: + wanted_scopes = ALL_SCOPES + cli_keys = [ + (sc_cli, sc_scope) + for (sc_cli, sc_scope) in state + if sc_cli == cli and sc_scope in wanted_scopes + ] + if not cli_keys: + label = f"{cli}:{args.scope}" if args.scope and cli == "claude" else cli + print(f"[{label}] no state entry — skip") + continue + # Unwind in reverse-registration order (LIFO) — sort by the + # explicit ``SwapEntry.seq_no`` counter so order is independent + # of JSON parse order, dict iteration, and wall-clock + # collisions. ``seq_no`` is coerced to ``int`` at load time by + # ``_parse_state_entry``; entries with a non-coercible value + # are dropped before they reach this sort, so the comparison + # is always int vs int. When two scopes back the same physical + # file (Claude user + project), the later swap's backup + # contains the earlier swap's modifications, so each backup + # must restore its own layer before the prior one is restored. + # Same explicit counter pattern CPython's ``Lib/sched.py`` uses + # to break ties on ``Event(time, priority, sequence, …)``. + cli_keys.sort(key=lambda k: state[k].seq_no, reverse=True) + for key in cli_keys: + sc_cli, sc_scope = key + entry = state[key] + label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli + backup = pathlib.Path(entry.backup_path) + dest = pathlib.Path(entry.config_path) + if not backup.exists(): + print(f"[{label}] backup missing: {backup}", file=sys.stderr) + continue + if args.dry_run: + print(f"[{label}] would restore {dest} from {backup}") + continue + atomic_write(dest, backup.read_bytes()) + # Backup served its purpose; LIFO unwind for this layer is + # complete. Delete on success, keep on error — same idiom + # CPython's ``tempfile.NamedTemporaryFile`` uses + # (Lib/tempfile.py:614-618). If ``atomic_write`` had raised, + # this line wouldn't run and the backup would survive for + # post-mortem; on success the backup is redundant and would + # otherwise accumulate forever across swap/revert cycles. + backup.unlink() + print(f"[{label}] restored from {backup}") + reverted.append(key) + + if not args.dry_run and reverted: + clear_state(reverted) + return 0 + + +# --------------------------------------------------------------------------- +# argparse glue +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + """Construct the ``argparse`` parser for ``mcp_swap``.""" + p = argparse.ArgumentParser(prog="mcp_swap", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser( + "detect", help="list installed CLIs and their config presence" + ).set_defaults(func=cmd_detect) + + ps = sub.add_parser("status", help="show the current MCP server entry per CLI") + ps.add_argument("--repo", default=".", help="repo root (default: .)") + ps.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + ps.add_argument( + "--cli", action="append", choices=ALL_CLIS, help="limit to one or more CLIs" + ) + ps.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit Claude output to one scope: 'user' shows only the " + "top-level mcpServers fallback, 'project' shows only the " + "projects..mcpServers entry. Without this flag, both " + "Claude scopes print when both have an entry. No-op for " + "non-Claude CLIs (their config has no per-project layer)." + ), + ) + ps.set_defaults(func=cmd_status) + + pu = sub.add_parser("use-local", help="rewrite configs to run this checkout") + pu.add_argument("--repo", default=".", help="repo root (default: .)") + pu.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + pu.add_argument( + "--entry", help="uv run entry command (default: [project.scripts] first key)" + ) + pu.add_argument("--cli", action="append", choices=ALL_CLIS) + pu.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Claude config scope: 'user' rewrites the top-level mcpServers " + "fallback (every project without an override picks it up), " + "'project' rewrites projects..mcpServers under this repo. " + "Default 'project'. Silently coerced to 'user' for non-Claude CLIs." + ), + ) + pu.add_argument("--dry-run", action="store_true") + pu.set_defaults(func=cmd_use_local) + + pr = sub.add_parser("revert", help="restore each CLI's config from its swap backup") + pr.add_argument("--cli", action="append", choices=ALL_CLIS) + pr.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit revert to one Claude scope. Without this flag, every " + "recorded scope for the targeted CLIs is reverted." + ), + ) + pr.add_argument("--dry-run", action="store_true") + pr.set_defaults(func=cmd_revert) + + return p + + +def main(argv: list[str] | None = None) -> int: + """Entry point — dispatches to the selected subcommand.""" + args = build_parser().parse_args(argv) + return t.cast("int", args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py new file mode 100644 index 000000000..e7261ed9d --- /dev/null +++ b/tests/test_mcp_swap.py @@ -0,0 +1,61 @@ +"""The ported ``scripts/mcp_swap.py`` dev tool resolves this repo's identity. + +``mcp_swap`` swaps MCP server configs across agent CLIs to point at a local +checkout. The only port-specific change is the slug derivation: this repo's +package is ``libtmux`` but its MCP console script is ``libtmux-engine-mcp``, so +the slug must come from the *entry* (yielding ``libtmux-engine``) to stay +distinct from a sibling ``libtmux`` server. These tests lock that in, plus the +packaging wiring that makes the server runnable. +""" + +from __future__ import annotations + +import importlib.metadata +import importlib.util +import pathlib +import sys +import typing as t + +import pytest + +_REPO = pathlib.Path(__file__).resolve().parent.parent +_SCRIPT = _REPO / "scripts" / "mcp_swap.py" + + +def _load_mcp_swap() -> t.Any: + """Import the PEP 723 script as a module (registered so dataclasses resolve).""" + spec = importlib.util.spec_from_file_location("mcp_swap", _SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["mcp_swap"] = module + spec.loader.exec_module(module) + return module + + +def test_console_script_registered() -> None: + """The ``libtmux-engine-mcp`` console script points at a loadable entry.""" + scripts = importlib.metadata.entry_points(group="console_scripts") + entry = next((ep for ep in scripts if ep.name == "libtmux-engine-mcp"), None) + assert entry is not None + assert entry.value == "libtmux.experimental.mcp:main" + + +def test_resolve_repo_meta_derives_engine_identity() -> None: + """Slug derives from the entry (``libtmux-engine``), not project.name.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + server, entry = mcp_swap.resolve_repo_meta(_REPO) + assert server == "libtmux-engine" + assert entry == "libtmux-engine-mcp" + + +def test_build_local_spec_uv_directory() -> None: + """``use-local`` writes a ``uv --directory run `` invocation.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + _, entry = mcp_swap.resolve_repo_meta(_REPO) + spec = mcp_swap.build_local_spec(_REPO, entry) + assert spec.command == "uv" + assert spec.args == ["--directory", str(_REPO), "run", "libtmux-engine-mcp"] + assert spec.is_local_uv_directory() From 7d341d0487cded423366ae63fde81cb932984f63 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 21:44:33 -0500 Subject: [PATCH 064/223] Tests(chore): Run the mcp adapter suite in the gate why: fastmcp is only the optional `mcp` extra, so a plain `uv sync` prunes it -- which silently turns `uv run mypy` red and makes every fastmcp adapter test importorskip away. The committed adapter's green gate depended on fastmcp happening to be installed. what: - Add fastmcp + tomlkit to the dev and testing dependency-groups so the standard gate type-checks and runs the adapter + mcp_swap tests (fastmcp also stays the `mcp` extra for end users) - Add --ignore=docs/_build to pytest addopts: `docs` is a testpath, so a stale built-HTML tree poisons collection (the gate's rm docs/_build first-step was the only guard) - Reformat ops/plan.py (pre-existing blank-line drift surfaced by ruff) - uv.lock: add tomlkit (no other version churn) --- pyproject.toml | 15 ++++++++++++++- uv.lock | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f3fbf23b1..90ba377af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,12 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + # MCP adapter — the optional `mcp` extra, included here so the gate + # type-checks (mypy) and exercises the fastmcp adapter + its tests + # instead of silently skipping them. + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", # Coverage "codecov", "coverage", @@ -88,6 +94,10 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + # MCP adapter (optional `mcp` extra) so the adapter tests run here too + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", ] coverage =[ "codecov", @@ -315,7 +325,10 @@ addopts = [ "--showlocals", "--doctest-docutils-modules", "-p no:doctest", - "--reruns=2" + "--reruns=2", + # Built HTML lives under docs/_build and `docs` is a testpath; never + # collect generated artifacts (their relative directives fail to parse). + "--ignore=docs/_build", ] doctest_optionflags = [ "ELLIPSIS", diff --git a/uv.lock b/uv.lock index 5dd39f1d7..ca061ad1b 100644 --- a/uv.lock +++ b/uv.lock @@ -1164,6 +1164,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "fastmcp" }, { name = "gp-libs" }, { name = "gp-sphinx" }, { name = "mypy" }, @@ -1178,6 +1179,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, + { name = "tomlkit" }, { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, @@ -1195,11 +1197,13 @@ lint = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ + { name = "fastmcp" }, { name = "gp-libs" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -1216,6 +1220,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "fastmcp" }, { name = "gp-libs", specifier = ">=0.0.19" }, { name = "gp-sphinx", specifier = "==0.0.1a34" }, { name = "mypy" }, @@ -1229,6 +1234,7 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a34" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a34" }, + { name = "tomlkit" }, { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, @@ -1245,11 +1251,13 @@ lint = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ + { name = "fastmcp" }, { name = "gp-libs", specifier = ">=0.0.19" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -2911,6 +2919,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "ty" version = "0.0.57" From 761d2f43267d2218f10ecbe31a85f4aa52425963 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 17:33:56 -0500 Subject: [PATCH 065/223] Workspace(test): Cover analyzer, compiler, runner why: The Declarative WorkspaceBuilder tier had thin coverage -- a single analyzer shorthand case, and nothing exercising the compiler's host-step schedule or the runner's on_exists preflight policy. Lock in those behaviors so a regression in the Declarative-to-Core lowering or the host-side orchestration is caught. what: - Analyzer/IR (offline): dimensions in both [x, y] and {width, height} forms; shell_command shorthand (string / list / {cmd} items); the None-pane and unsupported-pane TypeError paths; non-mapping-YAML rejection; session-field passthrough; per-pane orchestration fields; and Pane.commands run-form normalization - Compiler (offline): dimensions threaded into new-session -x/-y; env/option/window-option ops emitted with their values; the before_script and pane sleep_before/sleep_after host-step schedule asserted off the pure op spine (anchored by send-keys position, not literal index); first-window reuse vs create-the-rest; and Workspace.compile() == compile_full().plan - Runner/confirm (live tmux): before_script runs as a host step in start_directory; on_exists='reuse' short-circuits to an empty-but-ok result leaving the session untouched while 'error' raises FileExistsError; and confirm() flags a structural mismatch --- ..._async_control_engine_workspace_builder.py | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 33eff2d77..826bb4033 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -27,12 +27,20 @@ FoldingPlanner, LazyPlan, MarkedPlanner, + NewSession, SequentialPlanner, + SetEnvironment, + SetOption, + SetWindowOption, ) from libtmux.experimental.workspace import ( + HostStep, + Pane, + Window, Workspace, WorkspaceCompileError, analyze, + compile_full, confirm, ) from libtmux.test.retry import retry_until @@ -307,3 +315,310 @@ async def main() -> PlanResult: assert [w.window_name for w in built.windows] == ["editor", "logs", "shell"] assert built.active_window.window_name == "shell" assert str(built.show_option("history-limit")) == "5000" + + +# --- Analyzer + IR normalization (offline, no tmux) --- + + +def test_pane_commands_normalizes_run_forms() -> None: + """Pane.commands turns run (None / str / sequence) into a command tuple.""" + assert Pane().commands == () + assert Pane(run="vim").commands == ("vim",) + assert Pane(run=["cd src", "pytest -q"]).commands == ("cd src", "pytest -q") + + +def test_analyze_dimensions_list_and_mapping() -> None: + """The analyzer coerces both ``[x, y]`` and ``{width, height}`` dimensions.""" + panes = {"windows": [{"panes": ["echo a"]}]} + listed = analyze({"session_name": "s", "dimensions": [200, 50], **panes}) + mapped = analyze( + {"session_name": "s", "dimensions": {"width": 100, "height": 40}, **panes}, + ) + unset = analyze({"session_name": "s", **panes}) + assert listed.dimensions == (200, 50) + assert mapped.dimensions == (100, 40) + assert unset.dimensions is None + + +def test_analyze_shell_command_shorthand_forms() -> None: + """shell_command shorthand expands: bare string, list, and ``{cmd}`` items.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + {"shell_command": "echo solo"}, + {"shell_command": ["echo a", {"cmd": "echo b"}]}, + None, + ], + }, + ], + }, + ) + panes = ws.windows[0].panes + assert panes[0].commands == ("echo solo",) + assert panes[1].commands == ("echo a", "echo b") + assert panes[2].commands == () # a None pane is an empty (implicit) pane + + +def test_analyze_passes_through_session_fields() -> None: + """Session-level fields (env/options/before_script/on_exists) survive analysis.""" + ws = analyze( + { + "session_name": "s", + "on_exists": "replace", + "before_script": "echo setup", + "environment": {"E": "1"}, + "options": {"history-limit": "5000"}, + "windows": [{"panes": ["echo a"]}], + }, + ) + assert ws.on_exists == "replace" + assert ws.before_script == "echo setup" + assert dict(ws.environment) == {"E": "1"} + assert dict(ws.options) == {"history-limit": "5000"} + + +def test_analyze_normalizes_pane_orchestration_fields() -> None: + """Per-pane orchestration (sleeps, start_directory, focus) lands on the Pane.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + { + "shell_command": ["echo x"], + "sleep_before": 0.1, + "sleep_after": 0.2, + "start_directory": "/tmp", + "focus": True, + }, + ], + }, + ], + }, + ) + pane = ws.windows[0].panes[0] + assert (pane.sleep_before, pane.sleep_after) == (0.1, 0.2) + assert pane.start_directory == "/tmp" + assert pane.focus is True + + +def test_analyze_rejects_non_mapping_yaml() -> None: + """A YAML scalar (not a mapping) is rejected rather than silently mis-parsed.""" + with pytest.raises(TypeError): + analyze("just-a-scalar") + + +def test_analyze_rejects_unsupported_pane() -> None: + """A pane that is neither None, a string, nor a mapping fails closed.""" + with pytest.raises(TypeError): + analyze({"session_name": "s", "windows": [{"panes": [123]}]}) + + +# --- Compiler: op emission + host-step schedule (offline, no tmux) --- + + +def test_compile_threads_dimensions_into_new_session() -> None: + """Workspace dimensions become the new-session ``-x``/``-y`` width/height.""" + ws = Workspace( + name="ws-dim", + dimensions=(120, 40), + windows=[Window("w", panes=[Pane(run="echo a")])], + ) + new_session = compile_full(ws).plan.operations[0] + assert isinstance(new_session, NewSession) # first op, narrowed for its fields + assert (new_session.width, new_session.height) == (120, 40) + + +def test_compile_emits_environment_and_options() -> None: + """Session env/options and window options compile to their write ops, valued.""" + ws = Workspace( + name="ws-opts", + environment={"WS_E": "1"}, + options={"history-limit": "9000"}, + windows=[ + Window("w", options={"main-pane-height": "10"}, panes=[Pane(run="echo a")]), + ], + ) + ops = compile_full(ws).plan.operations + set_env = next(op for op in ops if isinstance(op, SetEnvironment)) + set_opt = next(op for op in ops if isinstance(op, SetOption)) + set_wopt = next(op for op in ops if isinstance(op, SetWindowOption)) + assert (set_env.name, set_env.value) == ("WS_E", "1") + assert (set_opt.option, set_opt.value) == ("history-limit", "9000") + assert (set_wopt.option, set_wopt.value) == ("main-pane-height", "10") + + +def test_compile_schedules_host_steps_off_the_op_spine() -> None: + """before_script and pane sleeps become host steps, not recorded operations.""" + ws = Workspace( + name="ws-hosts", + start_directory="/tmp", + before_script="echo hi", + windows=[ + Window( + "w", + panes=[ + Pane(run="echo a", sleep_before=0.5), + Pane(run="echo b", sleep_after=0.7), + ], + ), + ], + ) + compiled = compile_full(ws) + operations = compiled.plan.operations + + # no orchestration leaks into the pure op spine + assert {"sleep", "script"}.isdisjoint(op.kind for op in operations) + + # before_script runs before any op, carrying the session cwd + assert compiled.pre == (HostStep("script", command="echo hi", cwd="/tmp"),) + + # sleep_before is anchored just before its pane's first send-keys; + # sleep_after just after the last send-keys -- asserted by position, not index + sends = [i for i, op in enumerate(operations) if op.kind == "send_keys"] + assert HostStep("sleep", seconds=0.5) in compiled.host_after[min(sends) - 1] + assert HostStep("sleep", seconds=0.7) in compiled.host_after[max(sends)] + + +def test_compile_reuses_first_window_creating_only_the_rest() -> None: + """Window 0 reuses the session's implicit window; only 2..N create windows.""" + unnamed = compile_full(Workspace(name="s", windows=[Window(panes=[Pane(run="x")])])) + unnamed_kinds = [op.kind for op in unnamed.plan.operations] + assert "new_window" not in unnamed_kinds + assert "rename_window" not in unnamed_kinds # nothing to rename when unnamed + + named = compile_full(Workspace(name="s", windows=[Window("w", panes=[Pane("x")])])) + named_kinds = [op.kind for op in named.plan.operations] + assert "new_window" not in named_kinds + assert named_kinds.count("rename_window") == 1 # first window renamed in place + + two = compile_full( + Workspace( + name="s", + windows=[Window("a", panes=[Pane("x")]), Window("b", panes=[Pane("y")])], + ), + ) + assert [op.kind for op in two.plan.operations].count("new_window") == 1 + + +def test_compile_workspace_method_matches_compile_full_plan() -> None: + """``Workspace.compile()`` returns exactly ``compile_full().plan`` (same ops).""" + ws = Workspace(name="s", windows=[Window("w", panes=[Pane("echo a"), Pane("b")])]) + via_method = [op.kind for op in ws.compile().operations] + via_full = [op.kind for op in compile_full(ws).plan.operations] + assert via_method == via_full + + +# --- Runner preflight + confirm negative path (live tmux) --- + + +def test_workspace_before_script_runs_as_host_step( + session: Session, + tmp_path: Path, +) -> None: + """before_script executes on the host, in start_directory, before the build.""" + server = session.server + sentinel = tmp_path / "before_script.ran" + spec = analyze( + { + "session_name": "ws-before", + "start_directory": str(tmp_path), + "on_exists": "replace", + # relative path -> proves the step runs with cwd == start_directory + "before_script": f"echo ok > {sentinel.name}", + "windows": [{"window_name": "w", "panes": ["echo a"]}], + }, + ) + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + assert sentinel.exists() + assert sentinel.read_text().strip() == "ok" + + +def test_workspace_on_exists_reuse_skips_existing( + session: Session, + tmp_path: Path, +) -> None: + """on_exists='reuse' leaves an existing session untouched and skips the build.""" + server = session.server + engine = SubprocessEngine.for_server(server) + spec = analyze( + { + "session_name": "ws-reuse", + "start_directory": str(tmp_path), + "on_exists": "reuse", + "windows": [{"window_name": "only", "panes": ["echo a"]}], + }, + ) + + assert spec.build(engine).ok + before = [ + w.window_id for w in server.sessions.filter(session_name="ws-reuse")[0].windows + ] + + # the second build sees the session and short-circuits: empty but ok + second = spec.build(engine) + assert second.ok + assert second.results == () + after = [ + w.window_id for w in server.sessions.filter(session_name="ws-reuse")[0].windows + ] + assert before == after # untouched -- same windows, not rebuilt + + +def test_workspace_on_exists_error_raises( + session: Session, + tmp_path: Path, +) -> None: + """on_exists='error' refuses to clobber an existing session of the same name.""" + server = session.server + engine = SubprocessEngine.for_server(server) + spec = analyze( + { + "session_name": "ws-error", + "start_directory": str(tmp_path), + "on_exists": "error", + "windows": [{"window_name": "w", "panes": ["echo a"]}], + }, + ) + + assert spec.build(engine).ok + with pytest.raises(FileExistsError): + spec.build(engine) + + +def test_workspace_confirm_detects_structural_mismatch( + session: Session, + tmp_path: Path, +) -> None: + """Confirm flags a problem when the live session diverges from the spec.""" + server = session.server + built = analyze( + { + "session_name": "ws-confirm", + "start_directory": str(tmp_path), + "on_exists": "replace", + "windows": [{"window_name": "only", "panes": ["echo a"]}], + }, + ) + assert built.build(SubprocessEngine.for_server(server)).ok + assert confirm(built, server).ok # matches what was actually built + + # a spec declaring more windows than were built must be flagged + divergent = analyze( + { + "session_name": "ws-confirm", + "windows": [ + {"window_name": "only", "panes": ["echo a"]}, + {"window_name": "extra", "panes": ["echo b"]}, + ], + }, + ) + report = confirm(divergent, server) + assert not report.ok + assert any("window count" in problem for problem in report.problems) From bf41567a185f7cd1604d4f99a5e1272aa31c26d1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 17:36:20 -0500 Subject: [PATCH 066/223] Mcp(feat): Add grok + agy CLIs to mcp_swap why: The swap tool covered Claude / Codex / Cursor / Gemini but not the Grok or Antigravity (agy) CLIs, so a local-checkout swap could not reach two installed agents. Extending the registry lets one use-local repoint the tmux MCP across all six. what: - Register grok (~/.grok/config.toml, TOML "mcp_servers" table, same shape as codex) and agy/Antigravity (~/.gemini/antigravity/mcp_config.json, JSON "mcpServers", same shape as cursor/gemini) in CLIName / ALL_CLIS / CLIS - Route grok through the existing codex branch and agy through the cursor/gemini branch in get_server / set_server / delete_server - Tolerate an empty JSON config in load_config so the swap can seed Antigravity's initially-empty mcp_config.json instead of raising - Note in the docstring that the Antigravity IDE and the agy CLI may read different profiles; only the documented profile path is written - Tests: grok (TOML) and agy (JSON) set/get/delete round-trips, the empty-JSON tolerance, and the registry shapes --- scripts/mcp_swap.py | 55 +++++++++++++++++++++++++++++---------- tests/test_mcp_swap.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 6e68b6d1c..db487ddad 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -3,7 +3,7 @@ # requires-python = ">=3.10" # dependencies = ["tomlkit>=0.13"] # /// -"""Swap MCP server configs across Claude / Codex / Cursor / Gemini CLIs. +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini / Grok / Antigravity. Use when you want every installed agent CLI to run a local checkout of an MCP server (editable) instead of a pinned release. ``use-local`` rewrites @@ -35,8 +35,13 @@ This script is best-effort and intentionally narrow: - **Global configs only.** Writes to ``~/.cursor/mcp.json``, - ``~/.claude.json``, ``~/.codex/config.toml``, and - ``~/.gemini/settings.json``. Workspace / project-local configs + ``~/.claude.json``, ``~/.codex/config.toml``, + ``~/.gemini/settings.json``, ``~/.grok/config.toml`` (TOML + ``mcp_servers``, same shape as Codex), and + ``~/.gemini/antigravity/mcp_config.json`` (Antigravity, JSON + ``mcpServers``). The Antigravity desktop IDE and the ``agy`` CLI may + read different profiles; only the documented profile path above is + written. Workspace / project-local configs (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, per-project ``projects..mcpServers`` entries inside ``~/.claude.json`` *are* recognised for Claude only) are NOT @@ -83,8 +88,8 @@ import tomlkit import tomlkit.items -CLIName = t.Literal["claude", "codex", "cursor", "gemini"] -ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini") +CLIName = t.Literal["claude", "codex", "cursor", "gemini", "grok", "agy"] +ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini", "grok", "agy") #: Claude config scope: ``"user"`` targets the user/system-level top-level #: ``mcpServers`` fallback that applies to every project without its own @@ -219,6 +224,24 @@ class CLIInfo: config_path=pathlib.Path.home() / ".gemini" / "settings.json", fmt="json", ), + "grok": CLIInfo( + name="grok", + binary="grok", + config_path=pathlib.Path.home() / ".grok" / "config.toml", + fmt="toml", + ), + # Antigravity (the ``agy`` CLI). Its MCP config is the standard JSON + # ``mcpServers`` shape (same as Cursor / Gemini) under the + # Antigravity profile dir. The file may not exist until the IDE/CLI + # writes it and starts empty; ``load_config`` tolerates a 0-byte + # JSON file as ``{}``. Note: the desktop IDE and the ``agy`` CLI may + # read different profiles; this targets the documented profile path. + "agy": CLIInfo( + name="agy", + binary="agy", + config_path=pathlib.Path.home() / ".gemini" / "antigravity" / "mcp_config.json", + fmt="json", + ), } @@ -292,10 +315,16 @@ class SwapEntry: def load_config(info: CLIInfo) -> t.Any: - """Parse a CLI's config file (JSON or TOML) into an editable structure.""" + """Parse a CLI's config file (JSON or TOML) into an editable structure. + + An empty JSON file is treated as an empty object ``{}`` rather than a + parse error: Antigravity's ``mcp_config.json`` is created empty until + a server is added, so a swap must be able to seed the first entry. + """ raw = info.config_path.read_bytes() if info.fmt == "json": - return json.loads(raw) + text = raw.decode().strip() + return json.loads(text) if text else {} return tomlkit.parse(raw.decode()) @@ -459,9 +488,9 @@ def get_server( if not node: return None entry = node.get("mcpServers", {}).get(name) - elif cli in ("cursor", "gemini"): + elif cli in ("cursor", "gemini", "agy"): entry = config.get("mcpServers", {}).get(name) - else: # cli == "codex" + else: # cli in ("codex", "grok") — TOML "mcp_servers" table entry = config.get("mcp_servers", {}).get(name) if entry is None: return None @@ -496,12 +525,12 @@ def set_server( had = name in servers servers[name] = spec.to_json_dict(include_stdio_type=True) return "replaced" if had else "added" - if cli in ("cursor", "gemini"): + if cli in ("cursor", "gemini", "agy"): servers = config.setdefault("mcpServers", {}) had = name in servers servers[name] = spec.to_json_dict() return "replaced" if had else "added" - if cli == "codex": + if cli in ("codex", "grok"): # tomlkit: top-level tables are accessed via dict protocol too. mcp_servers = config.get("mcp_servers") if mcp_servers is None: @@ -547,9 +576,9 @@ def delete_server( return False servers = node.get("mcpServers", {}) return servers.pop(name, None) is not None - if cli in ("cursor", "gemini"): + if cli in ("cursor", "gemini", "agy"): return config.get("mcpServers", {}).pop(name, None) is not None - if cli == "codex": + if cli in ("codex", "grok"): mcp_servers = config.get("mcp_servers") if mcp_servers is None: return False diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index e7261ed9d..20f0e0f65 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -59,3 +59,62 @@ def test_build_local_spec_uv_directory() -> None: assert spec.command == "uv" assert spec.args == ["--directory", str(_REPO), "run", "libtmux-engine-mcp"] assert spec.is_local_uv_directory() + + +def test_grok_and_agy_registered() -> None: + """The grok and agy CLIs join the registry with their config shapes.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + assert "grok" in mcp_swap.ALL_CLIS + assert "agy" in mcp_swap.ALL_CLIS + assert mcp_swap.CLIS["grok"].fmt == "toml" + assert mcp_swap.CLIS["grok"].config_path.name == "config.toml" + assert mcp_swap.CLIS["agy"].fmt == "json" + assert mcp_swap.CLIS["agy"].config_path.name == "mcp_config.json" + + +def test_grok_set_get_delete_roundtrip() -> None: + """The grok CLI reads/writes the TOML ``[mcp_servers]`` table like codex.""" + tomlkit = pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + config = tomlkit.parse("") + spec = mcp_swap.McpServerSpec( + command="uv", args=["--directory", str(_REPO), "run", "x"] + ) + assert mcp_swap.set_server("grok", config, "tmux", spec, _REPO) == "added" + assert "mcp_servers" in config # TOML table, not the JSON "mcpServers" + got = mcp_swap.get_server("grok", config, "tmux", _REPO) + assert got is not None + assert got.is_local_uv_directory() + assert mcp_swap.set_server("grok", config, "tmux", spec, _REPO) == "replaced" + assert mcp_swap.delete_server("grok", config, "tmux", _REPO) + assert mcp_swap.get_server("grok", config, "tmux", _REPO) is None + + +def test_agy_set_get_delete_roundtrip() -> None: + """The agy CLI reads/writes the JSON ``mcpServers`` map like cursor/gemini.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + config: dict[str, t.Any] = {} + spec = mcp_swap.McpServerSpec( + command="uv", args=["--directory", str(_REPO), "run", "x"] + ) + assert mcp_swap.set_server("agy", config, "tmux", spec, _REPO) == "added" + # JSON (non-Claude) shape: no Claude-style "type", no empty "env" + assert "type" not in config["mcpServers"]["tmux"] + assert "env" not in config["mcpServers"]["tmux"] + got = mcp_swap.get_server("agy", config, "tmux", _REPO) + assert got is not None + assert got.is_local_uv_directory() + assert mcp_swap.delete_server("agy", config, "tmux", _REPO) + assert mcp_swap.get_server("agy", config, "tmux", _REPO) is None + + +def test_load_config_tolerates_empty_json(tmp_path: pathlib.Path) -> None: + """An empty JSON config (Antigravity's initial mcp_config.json) loads as {}.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + cfg = tmp_path / "mcp_config.json" + cfg.write_text("") + info = mcp_swap.CLIInfo(name="agy", binary="agy", config_path=cfg, fmt="json") + assert mcp_swap.load_config(info) == {} From 667f333b2ca7ebd485437da125ab8c7217ad05d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:23:59 -0500 Subject: [PATCH 067/223] Mcp(feat): Caller-aware async tmux tool surface why: The experimental MCP exposed only a thin synchronous projection. Agents driving tmux need an intuitive, non-blocking surface that knows which pane they are calling from, resolves "the pane relative to me" in one call, and never silently targets the wrong pane. what: - Refactor the curated vocabulary into an async-first package (session/window/pane/buffer/option/server): each tool is one async def over arun, with a derived sync twin via a sans-I/O trampoline (_bridge.synced) -- a single source of truth per tool. - Expand the lean curated set with high-value verbs and conveniences (grep_pane, capture_active_pane, geometry-resolved relative/corner pane tools, directional select_pane) plus a guarded run_tmux hatch. - Add build_async_server (default AsyncControlModeEngine) awaited on FastMCP's loop; build_server stays the sync wrapper; main() and fastmcp.json go async-first. - Add a live event stream (events.py): a push watch_events tool and a pull tmux://events ring buffer, selected by LIBTMUX_MCP_EVENTS. - Make the surface caller-aware: server name 'tmux' plus steering instructions (when/anti-triggers/concrete-id rule); CallerContext reads TMUX_PANE/TMUX from the server's own env, socket-scoped; get_caller_context anchor; is_caller on list_panes/search_panes rows; the relative tools default to and require the caller pane origin; capture_relative_pane/grep_relative_pane/search_panes. - Reject relative special targets ({up-of}/{down-of}/...) on capture, grep, send, and destructive pane tools with a hint pointing to the relative tools; anchor specials ({marked}/{last}) pass through. - Cover with experimental tests + doctests (no pytest-asyncio). --- fastmcp.json | 2 +- src/libtmux/experimental/mcp/__init__.py | 85 ++- src/libtmux/experimental/mcp/events.py | 233 +++++++ .../experimental/mcp/fastmcp_adapter.py | 481 +++++++++----- src/libtmux/experimental/mcp/vocabulary.py | 373 ----------- .../experimental/mcp/vocabulary/__init__.py | 226 +++++++ .../experimental/mcp/vocabulary/_bridge.py | 103 +++ .../experimental/mcp/vocabulary/_caller.py | 168 +++++ .../experimental/mcp/vocabulary/_geometry.py | 162 +++++ .../experimental/mcp/vocabulary/_resolve.py | 198 ++++++ .../experimental/mcp/vocabulary/_results.py | 106 ++++ .../experimental/mcp/vocabulary/buffer.py | 66 ++ .../experimental/mcp/vocabulary/option.py | 73 +++ .../experimental/mcp/vocabulary/pane.py | 599 ++++++++++++++++++ .../experimental/mcp/vocabulary/server.py | 71 +++ .../experimental/mcp/vocabulary/session.py | 134 ++++ .../experimental/mcp/vocabulary/window.py | 182 ++++++ tests/experimental/mcp/test_adapter_async.py | 93 +++ tests/experimental/mcp/test_caller.py | 193 ++++++ tests/experimental/mcp/test_events.py | 140 ++++ .../mcp/test_relative_special_guard.py | 119 ++++ .../mcp/test_vocabulary_extended.py | 243 +++++++ 22 files changed, 3516 insertions(+), 534 deletions(-) create mode 100644 src/libtmux/experimental/mcp/events.py delete mode 100644 src/libtmux/experimental/mcp/vocabulary.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/__init__.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_bridge.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_caller.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_geometry.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_resolve.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_results.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/buffer.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/option.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/pane.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/server.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/session.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/window.py create mode 100644 tests/experimental/mcp/test_adapter_async.py create mode 100644 tests/experimental/mcp/test_caller.py create mode 100644 tests/experimental/mcp/test_events.py create mode 100644 tests/experimental/mcp/test_relative_special_guard.py create mode 100644 tests/experimental/mcp/test_vocabulary_extended.py diff --git a/fastmcp.json b/fastmcp.json index f4fe92417..31bbfab36 100644 --- a/fastmcp.json +++ b/fastmcp.json @@ -3,7 +3,7 @@ "source": { "type": "filesystem", "path": "src/libtmux/experimental/mcp/__init__.py", - "entrypoint": "default_server" + "entrypoint": "default_async_server" }, "deployment": { "transport": "stdio" diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index a683f1933..2861957b7 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -70,11 +70,12 @@ def default_server(*, expose_operations: bool = False) -> FastMCP: - """Build a FastMCP server over a default :class:`~..engines.SubprocessEngine`. + """Build a synchronous FastMCP server over a :class:`~..engines.SubprocessEngine`. - A convenience factory (also the ``fastmcp.json`` entrypoint) for embedding or - deploying the server with the default tmux socket. Requires the ``mcp`` - extra (``pip install 'libtmux[mcp]'``). + A convenience factory for embedding or deploying the *synchronous* server + with the default tmux socket. Prefer :func:`default_async_server` for the + async-first surface and the live event stream. Requires the ``mcp`` extra + (``pip install 'libtmux[mcp]'``). """ from libtmux.experimental.engines import SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server @@ -82,41 +83,96 @@ def default_server(*, expose_operations: bool = False) -> FastMCP: return build_server(SubprocessEngine(), expose_operations=expose_operations) +def default_async_server( + *, + expose_operations: bool = False, + events: str = "push", + event_source: str = "subscription", +) -> FastMCP: + """Build the async-first FastMCP server over an :class:`AsyncControlModeEngine`. + + The default deployment: tools are awaited on FastMCP's loop and the live + event stream is wired up. The control-mode connection opens lazily on first + use. Requires the ``mcp`` extra. + """ + import typing as t + + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.events import EventMode, EventSource + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + return build_async_server( + AsyncControlModeEngine(), + expose_operations=expose_operations, + events=t.cast("EventMode", events), + event_source=t.cast("EventSource", event_source), + ) + + def main(argv: Sequence[str] | None = None) -> None: """Run the libtmux-engine MCP server over stdio (console-script entry). - Wired to the ``libtmux-engine-mcp`` console script and - ``python -m libtmux.experimental.mcp``. Requires the ``mcp`` extra. + Async-first by default (an :class:`AsyncControlModeEngine`); pass ``--sync`` + for the subprocess-backed synchronous server. Event mode/source default from + ``LIBTMUX_MCP_EVENTS`` / ``LIBTMUX_MCP_EVENT_SOURCE``. Wired to the + ``libtmux-engine-mcp`` console script and ``python -m + libtmux.experimental.mcp``. Requires the ``mcp`` extra. """ import argparse + import os import sys parser = argparse.ArgumentParser( prog="libtmux-engine-mcp", description="Run the experimental libtmux typed-ops MCP server (stdio).", ) - parser.add_argument("--name", default="libtmux-engine", help="server name") + parser.add_argument("--name", default="tmux", help="server name") parser.add_argument( "--operations", action="store_true", help="expose the full per-operation tool surface (op_*)", ) + parser.add_argument( + "--sync", + action="store_true", + help="use the synchronous subprocess server instead of async-first", + ) + parser.add_argument( + "--events", + choices=("off", "push", "pull", "both"), + default=os.environ.get("LIBTMUX_MCP_EVENTS", "push"), + help="live event mechanism (async server only)", + ) + parser.add_argument( + "--event-source", + choices=("subscription", "output"), + default=os.environ.get("LIBTMUX_MCP_EVENT_SOURCE", "subscription"), + help="event substrate (async server only)", + ) args = parser.parse_args(argv) try: - from libtmux.experimental.engines import SubprocessEngine - from libtmux.experimental.mcp.fastmcp_adapter import build_server + if args.sync: + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + + server = build_server( + SubprocessEngine(), + name=args.name, + expose_operations=args.operations, + ) + else: + server = default_async_server( + expose_operations=args.operations, + events=args.events, + event_source=args.event_source, + ) except ImportError: sys.stderr.write( "libtmux-engine-mcp requires the 'mcp' extra: pip install 'libtmux[mcp]'\n", ) raise SystemExit(1) from None - server = build_server( - SubprocessEngine(), - name=args.name, - expose_operations=args.operations, - ) server.run(transport="stdio") @@ -137,6 +193,7 @@ def main(argv: Sequence[str] | None = None) -> None: "capture_pane", "create_session", "create_window", + "default_async_server", "default_server", "execute_plan", "kill_pane", diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py new file mode 100644 index 000000000..2869884ce --- /dev/null +++ b/src/libtmux/experimental/mcp/events.py @@ -0,0 +1,233 @@ +"""Live tmux event stream over MCP -- two interchangeable mechanisms (A/B). + +A control-mode engine exposes tmux's asynchronous notifications (``%output``, +``%window-add``, ``%session-changed``, ...) as an ``async for`` stream via +``subscribe()``. FastMCP 3.x has no resource-subscription handshake and buffers a +tool's async generator into one list, so a live stream must be surfaced as +either: + +- **push** -- a long-running ``watch_events`` tool that holds a ``Context`` and + pushes each event as an MCP notification (real-time; best over streamable-http). +- **pull** -- a ``tmux://events`` resource backed by a ring buffer a background + task fills, plus a ``poll_events`` tool; clients poll (stdio-friendly). + +Which is registered is chosen by :func:`register_events` (driven by the +``LIBTMUX_MCP_EVENTS`` env var at the entrypoint). Both consume the engine's +single notification queue, so run one *or* the other per process when comparing. + +The ``source`` axis selects the substrate: ``"output"`` streams raw +notifications; ``"subscription"`` first installs ``refresh-client -B`` format +subscriptions, tmux's debounced, server-side change detection. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import typing as t + +from fastmcp import Context + +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator, Sequence + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine, CommandResult + +EventMode = t.Literal["off", "push", "pull", "both"] +EventSource = t.Literal["subscription", "output"] + +_RING_SIZE = 1024 + + +class _StreamEngine(t.Protocol): + """An async engine that also exposes a ``subscribe()`` notification stream. + + The general :class:`~..engines.base.AsyncTmuxEngine` protocol does not declare + ``subscribe`` (only the control-mode engine has it), so the event tools type + against this narrower protocol after the :func:`_supports_stream` guard. + """ + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute a batch of tmux commands.""" + ... + + def subscribe(self) -> AsyncIterator[t.Any]: + """Yield tmux notifications as they arrive.""" + ... + + +def _supports_stream(engine: AsyncTmuxEngine) -> bool: + """Whether *engine* exposes a ``subscribe()`` notification stream.""" + return callable(getattr(engine, "subscribe", None)) + + +def _event_dict(notification: t.Any) -> dict[str, t.Any]: + """Project a ``ControlNotification`` to a JSON-friendly dict.""" + return { + "kind": notification.kind, + "args": list(notification.args), + "raw": notification.raw, + } + + +async def _install_subscriptions( + engine: _StreamEngine, + specs: list[str] | None, +) -> None: + """Install ``refresh-client -B`` format subscriptions (``name:what:format``).""" + for spec in specs or []: + await engine.run(CommandRequest.from_args("refresh-client", "-B", spec)) + + +class _EventRing: + """A bounded ring buffer fed by a single background ``subscribe()`` reader. + + Each event gets a monotonic sequence number so a ``poll_events`` caller can + ask for "everything since N" without re-reading the whole buffer. + """ + + def __init__(self, engine: _StreamEngine, maxlen: int = _RING_SIZE) -> None: + self._engine = engine + self._buffer: collections.deque[tuple[int, dict[str, t.Any]]] = ( + collections.deque(maxlen=maxlen) + ) + self._seq = 0 + self._task: asyncio.Task[None] | None = None + + def _ensure_started(self) -> None: + """Start the drainer task once, lazily, on the running loop.""" + if self._task is None: + self._task = asyncio.create_task(self._drain(), name="libtmux-mcp-events") + + async def _drain(self) -> None: + """Copy every notification into the ring buffer.""" + stream: AsyncIterator[t.Any] = self._engine.subscribe() + async for notification in stream: + self._seq += 1 + self._buffer.append((self._seq, _event_dict(notification))) + + def since(self, seq: int) -> dict[str, t.Any]: + """Return buffered events with sequence number greater than *seq*.""" + self._ensure_started() + events = [event for n, event in self._buffer if n > seq] + return {"events": events, "cursor": self._seq} + + +def register_events( + mcp: FastMCP, + engine: AsyncTmuxEngine, + *, + mode: EventMode = "push", + source: EventSource = "subscription", +) -> None: + """Register the event stream tools/resource on *mcp* per *mode*. + + Does nothing when *mode* is ``"off"`` or *engine* has no ``subscribe()`` + stream (e.g. a subprocess engine) -- the live stream is a control-mode + feature. + """ + if mode == "off" or not _supports_stream(engine): + return + stream = t.cast("_StreamEngine", engine) + if mode in ("push", "both"): + _register_push(mcp, stream, source=source) + if mode in ("pull", "both"): + _register_pull(mcp, stream) + + +def _register_push( + mcp: FastMCP, + engine: _StreamEngine, + *, + source: EventSource, +) -> None: + """Register the long-running ``watch_events`` push tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + async def watch_events( + ctx: Context, + kinds: list[str] | None = None, + max_events: int = 20, + timeout: float = 30.0, + subscriptions: list[str] | None = None, + ) -> dict[str, t.Any]: + """Stream live tmux notifications, pushing each as an MCP log message. + + Returns after *max_events* notifications or *timeout* seconds, whichever + comes first. ``kinds`` filters by notification kind (e.g. ``window-add``, + ``output``). With ``source="subscription"``, pass ``subscriptions`` as + ``name:what:format`` specs to install ``refresh-client -B`` watches first. + """ + if source == "subscription": + await _install_subscriptions(engine, subscriptions) + collected: list[dict[str, t.Any]] = [] + + async def _collect() -> None: + async for notification in engine.subscribe(): + if kinds and notification.kind not in kinds: + continue + await ctx.info(notification.raw) + collected.append(_event_dict(notification)) + if max_events and len(collected) >= max_events: + return + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(_collect(), timeout=timeout) + return {"events": collected, "count": len(collected)} + + tool = FunctionTool.from_function( + watch_events, + name="watch_events", + description="Stream live tmux notifications as MCP messages", + tags={"readonly", "events"}, + annotations=ToolAnnotations(title="watch_events", readOnlyHint=True), + ) + mcp.add_tool(tool) + + +def _register_pull(mcp: FastMCP, engine: _StreamEngine) -> None: + """Register the ``tmux://events`` resource + ``poll_events`` pull tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + ring = _EventRing(engine) + + async def read_events() -> dict[str, t.Any]: + """Return all buffered tmux events (starts the reader on first read).""" + return ring.since(0) + + mcp.resource( + "tmux://events", + name="tmux-events", + description="Buffered tmux control-mode notifications", + )(read_events) + + async def poll_events(since: int = 0) -> dict[str, t.Any]: + """Return tmux events with sequence number greater than *since*. + + The response ``cursor`` is the latest sequence number; pass it back as + ``since`` next call to receive only newer events. + """ + return ring.since(since) + + tool = FunctionTool.from_function( + poll_events, + name="poll_events", + description="Poll buffered tmux events since a cursor", + tags={"readonly", "events"}, + annotations=ToolAnnotations(title="poll_events", readOnlyHint=True), + ) + mcp.add_tool(tool) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 05a5216da..363b5c380 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -2,34 +2,21 @@ This is the thin, framework-specific edge. It requires the ``mcp`` extra (``pip install libtmux[mcp]``); fastmcp is imported lazily so the rest of -:mod:`libtmux.experimental.mcp` stays dependency-free. :func:`build_server` -projects three layers of tools over one engine: +:mod:`libtmux.experimental.mcp` stays dependency-free. + +The vocabulary is **async-first**: :func:`build_async_server` registers the +``async def`` tools so FastMCP awaits them directly on its event loop (the right +fit for the persistent control-mode connection's loop affinity), and adds the +live event stream. :func:`build_server` is the synchronous wrapper -- it +registers the derived sync twins, which FastMCP offloads to a worker thread. + +Both project the same three tool layers over one engine: 1. **Curated vocabulary** -- the intuitive, hand-written tools (:mod:`~libtmux.experimental.mcp.vocabulary`), always visible. -2. **Per-operation tools** -- one ``op_`` per registered operation, - auto-derived from the :class:`~..registry.OperationToolRegistry`. These carry - a precomputed JSON schema (dynamic params), so each is a :class:`fastmcp.tools. - Tool` subclass with an explicit ``parameters`` schema rather than an - introspected signature. Tagged ``per-op`` and hidden by default (the full - surface is large); ``expose_operations=True`` reveals them. -3. **Plan tools** -- :func:`preview_plan`/:func:`execute_plan`/ - :func:`result_schema`/:func:`build_workspace`, taking serialized operations so - an agent can compose and run a whole :class:`~..ops.plan.LazyPlan`. - -The agent-facing ``engine`` is bound out of each tool's schema, and the safety -tier becomes the tool's ``ToolAnnotations`` + tag. - -Examples --------- ->>> import asyncio ->>> from fastmcp import Client # doctest: +SKIP ->>> from libtmux.experimental.engines import ConcreteEngine ->>> server = build_server(ConcreteEngine()) # doctest: +SKIP ->>> async def main(): # doctest: +SKIP -... async with Client(server) as client: -... return (await client.call_tool("create_session", {"name": "dev"})).data ->>> asyncio.run(main()) # doctest: +SKIP +2. **Per-operation tools** -- one ``op_`` per registered operation, hidden + behind the ``per-op`` tag by default (the full surface is large). +3. **Plan tools** -- compose and run a whole :class:`~..ops.plan.LazyPlan`. """ from __future__ import annotations @@ -40,49 +27,143 @@ from libtmux.experimental.mcp import vocabulary from libtmux.experimental.mcp.registry import OperationToolRegistry +from libtmux.experimental.mcp.vocabulary._caller import CallerContext if t.TYPE_CHECKING: from collections.abc import Callable from fastmcp import FastMCP - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.mcp.descriptor import ToolDescriptor - from libtmux.experimental.ops.plan import LazyPlan - -# (function, safety tier) -- every curated tool takes ``engine`` as its first arg. -_VOCABULARY: tuple[tuple[Callable[..., t.Any], str], ...] = ( - (vocabulary.create_session, "mutating"), - (vocabulary.create_window, "mutating"), - (vocabulary.split_pane, "mutating"), - (vocabulary.send_input, "mutating"), - (vocabulary.capture_pane, "readonly"), - (vocabulary.list_sessions, "readonly"), - (vocabulary.list_windows, "readonly"), - (vocabulary.list_panes, "readonly"), - (vocabulary.rename_window, "mutating"), - (vocabulary.rename_session, "mutating"), - (vocabulary.select_layout, "mutating"), - (vocabulary.select_pane, "mutating"), - (vocabulary.kill_pane, "destructive"), - (vocabulary.kill_window, "destructive"), - (vocabulary.kill_session, "destructive"), + from libtmux.experimental.mcp.events import EventMode, EventSource + +# (public tool name, safety tier). The async tool is ``a`` and the sync +# twin is ```` -- a single table drives both surfaces. +_TOOLS: tuple[tuple[str, str], ...] = ( + ("create_session", "mutating"), + ("create_window", "mutating"), + ("split_pane", "mutating"), + ("send_input", "mutating"), + ("capture_pane", "readonly"), + ("capture_active_pane", "readonly"), + ("grep_pane", "readonly"), + ("list_sessions", "readonly"), + ("list_windows", "readonly"), + ("list_panes", "readonly"), + ("list_clients", "readonly"), + ("has_session", "readonly"), + ("show_options", "readonly"), + ("show_buffer", "readonly"), + ("display_message", "readonly"), + ("resolve_relative_pane", "readonly"), + ("capture_relative_pane", "readonly"), + ("grep_relative_pane", "readonly"), + ("search_panes", "readonly"), + ("find_pane_by_position", "readonly"), + ("rename_window", "mutating"), + ("rename_session", "mutating"), + ("select_window", "mutating"), + ("select_layout", "mutating"), + ("select_pane", "mutating"), + ("move_window", "mutating"), + ("swap_window", "mutating"), + ("resize_pane", "mutating"), + ("swap_pane", "mutating"), + ("join_pane", "mutating"), + ("break_pane", "mutating"), + ("respawn_pane", "mutating"), + ("set_option", "mutating"), + ("set_buffer", "mutating"), + ("paste_buffer", "mutating"), + ("run_tmux", "mutating"), + ("kill_pane", "destructive"), + ("kill_window", "destructive"), + ("kill_session", "destructive"), ) -_INSTRUCTIONS = ( - "Drive tmux through typed tools. Targets accept tmux ids (%pane, @window, " - "$session), names, or 'session:window.pane'. Creating a session/window also " - "returns the new first-pane id for chaining. The curated tools cover most " - "needs; the full per-operation surface (op_*) and the plan tools " - "(preview_plan/execute_plan/result_schema/build_workspace) are available for " - "power use." +# Read-only discovery anchors -- the tools an agent should reach for first. +# Tagged with vendor metadata best-effort (fastmcp 3.4.2 passes ``_meta`` through +# but assigns it no semantics, so this is advisory only). +_ANCHORS = frozenset( + { + "list_panes", + "search_panes", + "grep_relative_pane", + "capture_active_pane", + "get_caller_context", + }, ) +# Fail loud at import if an anchor name drifts from the registered tool set +# (the alwaysLoad metadata is opaque, so a typo would otherwise fail silently). +_unknown_anchors = _ANCHORS - ({name for name, _ in _TOOLS} | {"get_caller_context"}) +if _unknown_anchors: # pragma: no cover - import-time guard + _msg = f"unknown anchor tools: {sorted(_unknown_anchors)}" + raise RuntimeError(_msg) + _TARGET_HELP = ( "tmux target: an id (%pane, @window, $session), a name, or 'session:window.pane'" ) +def _agent_context_segment(ctx: CallerContext) -> str: + """Return the agent-context paragraph naming the caller's pane.""" + if ctx.in_tmux and ctx.pane_id: + socket = ctx.socket_path or "default" + session = f" (session {ctx.session_id})" if ctx.session_id else "" + return ( + f"Agent context: this MCP runs from pane {ctx.pane_id} on socket " + f'{socket}{session}. That pane is flagged is_caller ("1" in ' + "list_panes rows, true in search_panes matches) -- call " + "get_caller_context to read it. " + "Omitting a target/origin on the caller-aware tools " + "(resolve_relative_pane/capture_relative_pane/grep_relative_pane) " + "means YOUR pane." + ) + return ( + "Agent context: this MCP is not running inside a tmux pane, so there is no " + "caller pane and no row is flagged is_caller; the relative tools " + "(resolve_relative_pane/capture_relative_pane/grep_relative_pane) require an " + "explicit origin pane id here." + ) + + +def _instructions(ctx: CallerContext) -> str: + """Compose the server instructions, woven with the live caller context.""" + return "\n\n".join( + ( + "This MCP drives a real tmux server through typed tools: sessions, " + "windows, panes, terminal scrollback, send-keys, copy-mode buffers. " + "Targets accept tmux ids (%pane, @window, $session), names, or " + "'session:window.pane'.", + "When to invoke: managing tmux panes/windows/sessions; reading " + "terminal scrollback (capture_pane/grep_pane/search_panes); sending " + "keystrokes to a running shell or REPL (send_input); copy-mode and " + "paste-buffer work; operating on a pane relative to another or to you " + "(capture_relative_pane/grep_relative_pane).", + "Do NOT invoke for: editor panes you edit via file tools; browser tabs " + "or web content; GUI application windows; notebook cells; any non-tmux " + "terminal surface. tmux only sees terminal panes -- it cannot read a " + "browser or GUI app.", + "Prefer a concrete %N pane id; resolve relative or caller-relative " + "targets to a concrete %N before capture/send. Never hand a directional " + "special target ({up-of}/{down-of}/{left-of}/{right-of}) to " + "capture_pane/grep_pane/send_input -- those resolve against THIS MCP's " + "control client, not your pane; use capture_relative_pane / " + "grep_relative_pane / resolve_relative_pane instead.", + _agent_context_segment(ctx), + "list_panes/list_windows/show_options query tmux metadata (format " + "fields); grep_pane (one pane) and search_panes (across panes) search " + "terminal text (scrollback). Pick the right one for 'which pane shows X'.", + "The curated tools cover most needs; the per-operation surface (op_*) " + "and the plan tools (preview_plan/execute_plan/result_schema/" + "build_workspace) are power-use; watch_events streams live notifications " + "on a control-mode engine.", + ), + ) + + def _summary(doc: str | None) -> str | None: """Return the first non-empty docstring line.""" for line in (doc or "").splitlines(): @@ -93,60 +174,101 @@ def _summary(doc: str | None) -> str | None: def _bind_engine( fn: Callable[..., t.Any], - engine: TmuxEngine, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, ) -> Callable[..., t.Any]: """Bind *engine* out of *fn*, returning a wrapper fastmcp can introspect. - Unlike :func:`functools.partial`, this carries *pre-resolved* annotations - (with ``engine`` removed) and an explicit ``__signature__``, so fastmcp's - ``get_type_hints`` never has to re-evaluate the forward references in *fn* - (it would do so against the wrong module globals and fail). + Carries *pre-resolved* annotations (with ``engine`` removed) and an explicit + ``__signature__`` so fastmcp's ``get_type_hints`` never re-evaluates the + forward references against the wrong module globals. The async branch returns + a coroutine function so FastMCP awaits it on the loop; the sync branch a plain + function (offloaded to a thread). """ hints = t.get_type_hints(fn) signature = inspect.signature(fn) params = [p for name, p in signature.parameters.items() if name != "engine"] - def tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + async def _async_tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + return await fn(engine, *args, **kwargs) + + def _sync_tool(*args: t.Any, **kwargs: t.Any) -> t.Any: return fn(engine, *args, **kwargs) + # Typed Any so the dunder rebinds below are not checked against a plain + # Callable (which carries no __name__/__signature__ in mypy's view). + tool: t.Any = _async_tool if is_async else _sync_tool tool.__name__ = fn.__name__ tool.__qualname__ = fn.__name__ tool.__doc__ = fn.__doc__ - tool.__signature__ = signature.replace(parameters=params) # type: ignore[attr-defined] + tool.__signature__ = signature.replace(parameters=params) tool.__annotations__ = {k: v for k, v in hints.items() if k != "engine"} - return tool + return t.cast("Callable[..., t.Any]", tool) -def register_vocabulary(mcp: FastMCP, engine: TmuxEngine) -> None: +def register_vocabulary( + mcp: FastMCP, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, +) -> None: """Register the curated vocabulary as tools on *mcp*, bound to *engine*.""" from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations - for fn, safety in _VOCABULARY: + for name, safety in _TOOLS: + fn = getattr(vocabulary, ("a" + name) if is_async else name) annotations = ToolAnnotations( - title=fn.__name__, + title=name, readOnlyHint=safety == "readonly", destructiveHint=safety == "destructive", ) tool = FunctionTool.from_function( - _bind_engine(fn, engine), - name=fn.__name__, + _bind_engine(fn, engine, is_async=is_async), + name=name, description=_summary(fn.__doc__), tags={safety}, annotations=annotations, + meta={"anthropic/alwaysLoad": True} if name in _ANCHORS else None, ) mcp.add_tool(tool) +def register_caller_context(mcp: FastMCP, ctx: CallerContext) -> None: + """Register the ``get_caller_context`` anchor returning the build-time context. + + It closes over the context read once from the server's environment -- it must + *not* re-query tmux, which would answer for the control client, not the + caller. + """ + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + def get_caller_context() -> CallerContext: + """Return the tmux pane/server that launched this MCP (from its env).""" + return ctx + + tool = FunctionTool.from_function( + get_caller_context, + name="get_caller_context", + description=_summary(get_caller_context.__doc__), + tags={"readonly"}, + annotations=ToolAnnotations(title="get_caller_context", readOnlyHint=True), + meta=( + {"anthropic/alwaysLoad": True} if "get_caller_context" in _ANCHORS else None + ), + ) + mcp.add_tool(tool) + + def _op_input_schema(descriptor: ToolDescriptor) -> dict[str, t.Any]: """Return the per-op tool's input schema, re-adding the target params. The :class:`~..registry.OperationToolRegistry` omits ``target`` / ``src_target`` from a descriptor's params (they are polymorphic - :data:`~..ops._types.Target` values, handled by - :meth:`~..descriptor.ToolDescriptor.build`), so the schema is re-completed - here -- at the framework edge -- as plain ``string`` params. Required-ness - follows the operation field's default. + :data:`~..ops._types.Target` values), so the schema is re-completed here -- + at the framework edge -- as plain ``string`` params. """ schema = descriptor.input_schema() fields = { @@ -174,25 +296,25 @@ def _op_input_schema(descriptor: ToolDescriptor) -> dict[str, t.Any]: def register_operations( mcp: FastMCP, - engine: TmuxEngine, + engine: TmuxEngine | AsyncTmuxEngine, *, + is_async: bool, registry: OperationToolRegistry | None = None, hidden: bool = True, ) -> None: """Register one ``op_`` tool per registered operation. Each tool carries the operation's precomputed JSON schema and dispatches to - :meth:`~..descriptor.ToolDescriptor.build` + :func:`~..ops.execute.run`, - returning the serialized result. Tools are tagged ``per-op`` plus their - safety tier; when *hidden* (the default) the ``per-op`` tag is disabled so - the large surface does not clutter an agent's tool list (re-enable with - ``mcp.enable(tags={"per-op"})``). + :meth:`~..descriptor.ToolDescriptor.build` + :func:`~..ops.execute.run` (or + :func:`~..ops.execute.arun` for an async engine), returning the serialized + result. Tools are tagged ``per-op`` plus their safety tier; when *hidden* + (the default) the ``per-op`` tag is disabled. """ from fastmcp.tools import Tool, ToolResult from mcp.types import ToolAnnotations from pydantic import PrivateAttr - from libtmux.experimental.ops import run as run_op + from libtmux.experimental.ops import arun as arun_op, run as run_op from libtmux.experimental.ops.serialize import result_to_dict class _OperationTool(Tool): @@ -200,10 +322,14 @@ class _OperationTool(Tool): _descriptor: t.Any = PrivateAttr(default=None) _engine: t.Any = PrivateAttr(default=None) + _is_async: bool = PrivateAttr(default=False) async def run(self, arguments: dict[str, t.Any]) -> ToolResult: operation = self._descriptor.build(**arguments) - result = run_op(operation, self._engine) + if self._is_async: + result = await arun_op(operation, self._engine) + else: + result = run_op(operation, self._engine) return ToolResult( structured_content=result_to_dict(result), is_error=not result.ok, @@ -225,6 +351,7 @@ async def run(self, arguments: dict[str, t.Any]) -> ToolResult: ) tool._descriptor = descriptor tool._engine = engine + tool._is_async = is_async mcp.add_tool(tool) if hidden: mcp.disable(tags={"per-op"}) @@ -232,17 +359,17 @@ async def run(self, arguments: dict[str, t.Any]) -> ToolResult: def register_plan_tools( mcp: FastMCP, - engine: TmuxEngine, + engine: TmuxEngine | AsyncTmuxEngine, *, + is_async: bool, registry: OperationToolRegistry | None = None, ) -> None: """Register the plan-tier tools (compose + run serialized :class:`LazyPlan`s). - ``preview_plan`` renders without executing; ``execute_plan`` runs a - serialized plan (forward refs resolved via bindings); ``result_schema`` - reports what a kind returns; ``build_workspace`` builds the Declarative tier - in one call. All take JSON-serializable inputs (operation dicts from - :func:`~..ops.serialize.operation_to_dict`). + ``preview_plan`` / ``result_schema`` are pure; ``execute_plan`` runs a + serialized plan (via :func:`~..mcp.plan_tools.aexecute_plan` on an async + engine). ``build_workspace`` is registered only on the synchronous server + (the declarative runner is synchronous). """ from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations @@ -270,6 +397,13 @@ def _plan_from_dicts(operations: list[dict[str, t.Any]]) -> LazyPlan: plan.add(operation_from_dict(data)) return plan + def _planner(name: str) -> Planner: + chosen = planners.get(name) + if chosen is None: + msg = f"unknown planner {name!r}; choose from {sorted(planners)}" + raise ValueError(msg) + return chosen() + def preview_plan( operations: list[dict[str, t.Any]], version: str | None = None, @@ -282,28 +416,6 @@ def preview_plan( "argv": [list(item) if item is not None else None for item in preview.argv], } - def execute_plan( - operations: list[dict[str, t.Any]], - planner: str = "sequential", - version: str | None = None, - ) -> dict[str, t.Any]: - """Execute a serialized plan over the engine; return results + bindings.""" - chosen = planners.get(planner) - if chosen is None: - msg = f"unknown planner {planner!r}; choose from {sorted(planners)}" - raise ValueError(msg) - outcome = _plan.execute_plan( - _plan_from_dicts(operations), - engine, - version=version, - planner=chosen(), - ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } - def result_schema(kind: str) -> dict[str, t.Any]: """Report what an operation kind returns, for planning forward refs.""" schema = _plan.result_schema(reg, kind) @@ -314,30 +426,73 @@ def result_schema(kind: str) -> dict[str, t.Any]: "binding_fields": schema.binding_fields, } - def build_workspace( - spec: dict[str, t.Any], - preflight: bool = True, - version: str | None = None, - ) -> dict[str, t.Any]: - """Build a declarative workspace (the Declarative tier) in one call.""" - outcome = _plan.build_workspace( - spec, - engine, - version=version, - preflight=preflight, - ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } - - tools: tuple[tuple[Callable[..., t.Any], str], ...] = ( + tools: list[tuple[Callable[..., t.Any], str]] = [ (preview_plan, "readonly"), (result_schema, "readonly"), - (execute_plan, "mutating"), - (build_workspace, "mutating"), - ) + ] + + if is_async: + + async def execute_plan( + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + outcome = await _plan.aexecute_plan( + _plan_from_dicts(operations), + t.cast("AsyncTmuxEngine", engine), + version=version, + planner=_planner(planner), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools.append((execute_plan, "mutating")) + else: + + def execute_plan( # type: ignore[misc] + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + outcome = _plan.execute_plan( + _plan_from_dicts(operations), + t.cast("TmuxEngine", engine), + version=version, + planner=_planner(planner), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + def build_workspace( + spec: dict[str, t.Any], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build a declarative workspace (the Declarative tier) in one call.""" + outcome = _plan.build_workspace( + spec, + t.cast("TmuxEngine", engine), + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools.append((execute_plan, "mutating")) + tools.append((build_workspace, "mutating")) + for fn, safety in tools: annotations = ToolAnnotations( title=fn.__name__, @@ -357,40 +512,74 @@ def build_workspace( def build_server( engine: TmuxEngine, *, - name: str = "libtmux-engine", + name: str = "tmux", instructions: str | None = None, include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, ) -> FastMCP: - """Build a FastMCP server exposing the typed tool surface over *engine*. - - Parameters - ---------- - engine - The :class:`~..engines.base.TmuxEngine` every tool runs against. - name, instructions - Server identity (``instructions`` defaults to a built-in primer). - include_operations - Register the auto-derived ``op_`` per-operation tools. - expose_operations - Reveal those per-operation tools by default (otherwise they are - registered but hidden behind the ``per-op`` tag). - include_plan_tools - Register the plan-tier tools. + """Build a synchronous FastMCP server over a sync *engine*. + + The sync wrapper: the curated tools are the derived sync twins, which FastMCP + offloads to a worker thread. Prefer :func:`build_async_server` for the + async-first surface and the event stream. """ from fastmcp import FastMCP - mcp: FastMCP = FastMCP(name=name, instructions=instructions or _INSTRUCTIONS) + ctx = CallerContext.from_env() + mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) + registry = OperationToolRegistry() + register_vocabulary(mcp, engine, is_async=False) + register_caller_context(mcp, ctx) + if include_operations: + register_operations( + mcp, + engine, + is_async=False, + registry=registry, + hidden=not expose_operations, + ) + if include_plan_tools: + register_plan_tools(mcp, engine, is_async=False, registry=registry) + return mcp + + +def build_async_server( + engine: AsyncTmuxEngine, + *, + name: str = "tmux", + instructions: str | None = None, + include_operations: bool = True, + expose_operations: bool = False, + include_plan_tools: bool = True, + events: EventMode = "push", + event_source: EventSource = "subscription", +) -> FastMCP: + """Build the async-first FastMCP server over an async *engine*. + + The curated tools and per-op/plan tools are registered as ``async`` and + awaited directly on FastMCP's event loop. When *engine* supports a + notification stream (a control-mode engine), the live event tools are + registered per *events* (``"push"``/``"pull"``/``"both"``/``"off"``). + """ + from fastmcp import FastMCP + + from libtmux.experimental.mcp.events import register_events + + ctx = CallerContext.from_env() + mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) registry = OperationToolRegistry() - register_vocabulary(mcp, engine) + register_vocabulary(mcp, engine, is_async=True) + register_caller_context(mcp, ctx) if include_operations: register_operations( mcp, engine, + is_async=True, registry=registry, hidden=not expose_operations, ) if include_plan_tools: - register_plan_tools(mcp, engine, registry=registry) + register_plan_tools(mcp, engine, is_async=True, registry=registry) + register_events(mcp, engine, mode=events, source=event_source) return mcp diff --git a/src/libtmux/experimental/mcp/vocabulary.py b/src/libtmux/experimental/mcp/vocabulary.py deleted file mode 100644 index ec44f519a..000000000 --- a/src/libtmux/experimental/mcp/vocabulary.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Curated core vocabulary -- the intuitive, named tmux tools. - -The Layer-1 surface: a small set of hand-written, framework-agnostic functions -that mirror libtmux's own ORM (``server.new_session`` / ``window.split_window`` / -``pane.send_keys``) but run over any engine and return small, typed result -objects exposing just the ids/names a caller cares about. Each is a thin wrapper: -resolve the target, build one operation, :func:`~..ops.execute.run` it, raise on -failure, return a typed result. Power users drop to the per-op descriptors, -plans, or ops directly. - -Examples --------- ->>> from libtmux.experimental.engines import ConcreteEngine ->>> engine = ConcreteEngine() ->>> session = create_session(engine, name="dev") ->>> session.session_id -'$1' ->>> pane = split_pane(engine, session.first_pane_id or "%1", horizontal=True) ->>> pane.pane_id -'%2' ->>> send_input(engine, pane.pane_id, "pytest -q", enter=True) is None -True -""" - -from __future__ import annotations - -import collections.abc -from dataclasses import dataclass - -from libtmux.experimental.engines.base import TmuxEngine -from libtmux.experimental.mcp.target_resolver import resolve_target -from libtmux.experimental.ops import ( - CapturePane, - KillPane, - KillSession, - KillWindow, - ListPanes, - ListSessions, - ListWindows, - NewSession, - NewWindow, - RenameSession, - RenameWindow, - SelectLayout, - SelectPane, - SendKeys, - SplitWindow, - run, -) -from libtmux.experimental.ops._types import Target - -# TmuxEngine / Target are imported at runtime (not under TYPE_CHECKING) so the -# fastmcp adapter's get_type_hints() can resolve these annotations when it builds -# tool schemas from these functions. - - -@dataclass(frozen=True) -class SessionResult: - """A created session: its id, name, and captured first window/pane ids.""" - - session_id: str - name: str | None = None - first_window_id: str | None = None - first_pane_id: str | None = None - - -@dataclass(frozen=True) -class WindowResult: - """A created window: its id, name, and captured first pane id.""" - - window_id: str - name: str | None = None - first_pane_id: str | None = None - - -@dataclass(frozen=True) -class PaneResult: - """A created pane: its id.""" - - pane_id: str - - -@dataclass(frozen=True) -class PaneCapture: - """Captured pane contents.""" - - lines: tuple[str, ...] - - -@dataclass(frozen=True) -class Listing: - """A list query result: one mapping (tmux format row) per object.""" - - rows: tuple[collections.abc.Mapping[str, str], ...] - - -def create_session( - engine: TmuxEngine, - *, - name: str | None = None, - start_directory: str | None = None, - environment: collections.abc.Mapping[str, str] | None = None, - width: int | None = None, - height: int | None = None, - version: str | None = None, -) -> SessionResult: - """Create a detached session (mirrors ``server.new_session``). - - Examples - -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> r = create_session(ConcreteEngine(), name="work") - >>> (r.session_id, r.name, r.first_pane_id) - ('$1', 'work', '%1') - """ - result = run( - NewSession( - session_name=name, - start_directory=start_directory, - environment=environment, - width=width, - height=height, - capture_panes=True, - ), - engine, - version=version, - ) - result.raise_for_status() - return SessionResult( - session_id=result.new_id or "", - name=name, - first_window_id=result.first_window_id, - first_pane_id=result.first_pane_id, - ) - - -def create_window( - engine: TmuxEngine, - target: str | Target, - *, - name: str | None = None, - start_directory: str | None = None, - version: str | None = None, -) -> WindowResult: - """Create a window in a session (mirrors ``session.new_window``).""" - result = run( - NewWindow( - target=resolve_target(target), - name=name, - start_directory=start_directory, - capture_pane=True, - ), - engine, - version=version, - ) - result.raise_for_status() - return WindowResult( - window_id=result.new_id or "", - name=name, - first_pane_id=result.first_pane_id, - ) - - -def split_pane( - engine: TmuxEngine, - target: str | Target, - *, - horizontal: bool = False, - start_directory: str | None = None, - version: str | None = None, -) -> PaneResult: - """Split a pane, creating a new one (mirrors ``window.split_window``).""" - result = run( - SplitWindow( - target=resolve_target(target), - horizontal=horizontal, - start_directory=start_directory, - ), - engine, - version=version, - ) - result.raise_for_status() - return PaneResult(pane_id=result.new_pane_id or "") - - -def send_input( - engine: TmuxEngine, - target: str | Target, - keys: str, - *, - enter: bool = False, - literal: bool = False, - suppress_history: bool = False, - version: str | None = None, -) -> None: - """Send keys to a pane (mirrors ``pane.send_keys``).""" - run( - SendKeys( - target=resolve_target(target), - keys=keys, - enter=enter, - literal=literal, - suppress_history=suppress_history, - ), - engine, - version=version, - ).raise_for_status() - - -def capture_pane( - engine: TmuxEngine, - target: str | Target, - *, - start: int | None = None, - end: int | None = None, - join_wrapped: bool = False, - trim_trailing: bool = False, - version: str | None = None, -) -> PaneCapture: - """Capture a pane's contents (mirrors ``pane.capture_pane``).""" - result = run( - CapturePane( - target=resolve_target(target), - start=start, - end=end, - join_wrapped=join_wrapped, - trim_trailing=trim_trailing, - ), - engine, - version=version, - ) - result.raise_for_status() - return PaneCapture(lines=result.lines) - - -def list_sessions(engine: TmuxEngine, *, version: str | None = None) -> Listing: - """List the server's sessions (mirrors ``server.sessions``).""" - result = run(ListSessions(), engine, version=version) - result.raise_for_status() - return Listing(rows=result.rows) - - -def list_windows( - engine: TmuxEngine, - target: str | Target | None = None, - *, - all_windows: bool = False, - version: str | None = None, -) -> Listing: - """List windows of a session, or all windows (mirrors ``session.windows``).""" - result = run( - ListWindows(target=resolve_target(target), all_windows=all_windows), - engine, - version=version, - ) - result.raise_for_status() - return Listing(rows=result.rows) - - -def list_panes( - engine: TmuxEngine, - target: str | Target | None = None, - *, - all_panes: bool = False, - version: str | None = None, -) -> Listing: - """List panes of a window, or all panes (mirrors ``window.panes``).""" - result = run( - ListPanes(target=resolve_target(target), all_panes=all_panes), - engine, - version=version, - ) - result.raise_for_status() - return Listing(rows=result.rows) - - -def kill_pane( - engine: TmuxEngine, - target: str | Target, - *, - others: bool = False, - version: str | None = None, -) -> None: - """Kill a pane (or all others in its window with ``others=True``).""" - run( - KillPane(target=resolve_target(target), others=others), - engine, - version=version, - ).raise_for_status() - - -def kill_window( - engine: TmuxEngine, - target: str | Target, - *, - others: bool = False, - version: str | None = None, -) -> None: - """Kill a window (or all others in its session with ``others=True``).""" - run( - KillWindow(target=resolve_target(target), others=others), - engine, - version=version, - ).raise_for_status() - - -def kill_session( - engine: TmuxEngine, - target: str | Target, - *, - version: str | None = None, -) -> None: - """Kill a session (mirrors ``session.kill``).""" - run( - KillSession(target=resolve_target(target)), engine, version=version - ).raise_for_status() - - -def rename_window( - engine: TmuxEngine, - target: str | Target, - name: str, - *, - version: str | None = None, -) -> None: - """Rename a window (mirrors ``window.rename_window``).""" - run( - RenameWindow(target=resolve_target(target), name=name), - engine, - version=version, - ).raise_for_status() - - -def rename_session( - engine: TmuxEngine, - target: str | Target, - name: str, - *, - version: str | None = None, -) -> None: - """Rename a session (mirrors ``session.rename_session``).""" - run( - RenameSession(target=resolve_target(target), name=name), - engine, - version=version, - ).raise_for_status() - - -def select_layout( - engine: TmuxEngine, - target: str | Target, - *, - layout: str | None = None, - version: str | None = None, -) -> None: - """Apply a layout to a window (mirrors ``window.select_layout``).""" - run( - SelectLayout(target=resolve_target(target), layout=layout), - engine, - version=version, - ).raise_for_status() - - -def select_pane( - engine: TmuxEngine, - target: str | Target, - *, - version: str | None = None, -) -> None: - """Make a pane active (mirrors ``window.select_pane``).""" - run( - SelectPane(target=resolve_target(target)), engine, version=version - ).raise_for_status() diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py new file mode 100644 index 000000000..f39e93832 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -0,0 +1,226 @@ +"""Curated core vocabulary -- the intuitive, named tmux tools. + +The Layer-1 surface: a small set of hand-written functions that mirror libtmux's +ORM (``server.new_session`` / ``window.split_window`` / ``pane.send_keys``) but +run over any engine and return small, typed result objects. Each tool is written +once as an ``async def`` (the ``a``-prefixed names, the canonical async-first +surface) and exposed as a derived synchronous twin under the plain name (see +:mod:`._bridge`). Tools are grouped by scope in submodules +(:mod:`.session` / :mod:`.window` / :mod:`.pane` / :mod:`.buffer` / +:mod:`.option` / :mod:`.server`). + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> engine = ConcreteEngine() +>>> session = create_session(engine, name="dev") +>>> session.session_id +'$1' +>>> pane = split_pane(engine, session.first_pane_id or "%1", horizontal=True) +>>> pane.pane_id +'%2' +>>> send_input(engine, pane.pane_id, "pytest -q", enter=True) is None +True +""" + +from __future__ import annotations + +from libtmux.experimental.mcp.vocabulary._caller import CallerContext +from libtmux.experimental.mcp.vocabulary._results import ( + BufferText, + Listing, + MessageText, + OptionMap, + PaneCapture, + PaneMatch, + PaneRef, + PaneResult, + PaneSearch, + RawResult, + SessionResult, + WindowResult, +) +from libtmux.experimental.mcp.vocabulary.buffer import ( + apaste_buffer, + aset_buffer, + ashow_buffer, + paste_buffer, + set_buffer, + show_buffer, +) +from libtmux.experimental.mcp.vocabulary.option import ( + aset_option, + ashow_options, + set_option, + show_options, +) +from libtmux.experimental.mcp.vocabulary.pane import ( + abreak_pane, + acapture_active_pane, + acapture_pane, + acapture_relative_pane, + afind_pane_by_position, + agrep_pane, + agrep_relative_pane, + ajoin_pane, + akill_pane, + alist_panes, + aresize_pane, + aresolve_relative_pane, + arespawn_pane, + asearch_panes, + aselect_pane, + asend_input, + asplit_pane, + aswap_pane, + break_pane, + capture_active_pane, + capture_pane, + capture_relative_pane, + find_pane_by_position, + grep_pane, + grep_relative_pane, + join_pane, + kill_pane, + list_panes, + resize_pane, + resolve_relative_pane, + respawn_pane, + search_panes, + select_pane, + send_input, + split_pane, + swap_pane, +) +from libtmux.experimental.mcp.vocabulary.server import ( + adisplay_message, + alist_clients, + arun_tmux, + display_message, + list_clients, + run_tmux, +) +from libtmux.experimental.mcp.vocabulary.session import ( + acreate_session, + ahas_session, + akill_session, + alist_sessions, + arename_session, + create_session, + has_session, + kill_session, + list_sessions, + rename_session, +) +from libtmux.experimental.mcp.vocabulary.window import ( + acreate_window, + akill_window, + alist_windows, + amove_window, + arename_window, + aselect_layout, + aselect_window, + aswap_window, + create_window, + kill_window, + list_windows, + move_window, + rename_window, + select_layout, + select_window, + swap_window, +) + +__all__ = ( + "BufferText", + "CallerContext", + "Listing", + "MessageText", + "OptionMap", + "PaneCapture", + "PaneMatch", + "PaneRef", + "PaneResult", + "PaneSearch", + "RawResult", + "SessionResult", + "WindowResult", + "abreak_pane", + "acapture_active_pane", + "acapture_pane", + "acapture_relative_pane", + "acreate_session", + "acreate_window", + "adisplay_message", + "afind_pane_by_position", + "agrep_pane", + "agrep_relative_pane", + "ahas_session", + "ajoin_pane", + "akill_pane", + "akill_session", + "akill_window", + "alist_clients", + "alist_panes", + "alist_sessions", + "alist_windows", + "amove_window", + "apaste_buffer", + "arename_session", + "arename_window", + "aresize_pane", + "aresolve_relative_pane", + "arespawn_pane", + "arun_tmux", + "asearch_panes", + "aselect_layout", + "aselect_pane", + "aselect_window", + "asend_input", + "aset_buffer", + "aset_option", + "ashow_buffer", + "ashow_options", + "asplit_pane", + "aswap_pane", + "aswap_window", + "break_pane", + "capture_active_pane", + "capture_pane", + "capture_relative_pane", + "create_session", + "create_window", + "display_message", + "find_pane_by_position", + "grep_pane", + "grep_relative_pane", + "has_session", + "join_pane", + "kill_pane", + "kill_session", + "kill_window", + "list_clients", + "list_panes", + "list_sessions", + "list_windows", + "move_window", + "paste_buffer", + "rename_session", + "rename_window", + "resize_pane", + "resolve_relative_pane", + "respawn_pane", + "run_tmux", + "search_panes", + "select_layout", + "select_pane", + "select_window", + "send_input", + "set_buffer", + "set_option", + "show_buffer", + "show_options", + "split_pane", + "swap_pane", + "swap_window", +) diff --git a/src/libtmux/experimental/mcp/vocabulary/_bridge.py b/src/libtmux/experimental/mcp/vocabulary/_bridge.py new file mode 100644 index 000000000..a473bbe97 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_bridge.py @@ -0,0 +1,103 @@ +"""Sync bridge: drive one async tool body over a synchronous engine. + +The curated vocabulary is written once as ``async def`` over an +:class:`~libtmux.experimental.engines.base.AsyncTmuxEngine` (the canonical, +"async-first" surface). A synchronous twin is *derived* -- not hand-written -- +by :func:`synced`, which wraps a plain :class:`~..engines.base.TmuxEngine` in the +async protocol (:class:`SyncToAsyncEngine`) and drives the coroutine to +completion with a sans-I/O trampoline (:func:`drive_sync`). + +This is sound because every curated tool's only ``await`` is a single +``arun(op, engine)`` -- and the wrapped sync engine's ``run`` returns inline, +never suspending on a real :class:`asyncio.Future`. The trampoline therefore +runs the whole coroutine in one ``send(None)``, needing no event loop, and works +even when called from inside a running loop. A tool that *does* suspend (the +event stream) has no sync twin and raises here, by design. +""" + +from __future__ import annotations + +import functools +import inspect +import typing as t + +# Imported at runtime (not under TYPE_CHECKING) so the derived sync twin's +# ``engine`` annotation resolves when the fastmcp adapter calls get_type_hints(). +from libtmux.experimental.engines.base import TmuxEngine + +if t.TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + +R = t.TypeVar("R") + + +class SyncToAsyncEngine: + """Adapt a synchronous :class:`TmuxEngine` to the async engine protocol. + + Each ``await`` resolves inline (the underlying call is synchronous), so a + coroutine awaiting only this adapter never yields to an event loop. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> bridge = SyncToAsyncEngine(ConcreteEngine()) + >>> hasattr(bridge, "run") and hasattr(bridge, "run_batch") + True + """ + + def __init__(self, engine: TmuxEngine) -> None: + self._engine = engine + + async def run(self, request: CommandRequest) -> CommandResult: + """Run one command on the wrapped sync engine (resolves inline).""" + return self._engine.run(request) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Run a batch on the wrapped sync engine (resolves inline).""" + return self._engine.run_batch(requests) + + +def drive_sync(coro: Awaitable[R]) -> R: + """Run *coro* to completion synchronously, without an event loop. + + Works only for coroutines whose awaits never suspend on a real future + (the curated tools, driven over a :class:`SyncToAsyncEngine`). + + Raises + ------ + RuntimeError + If the coroutine suspends on real I/O -- use the async surface instead. + """ + runner = t.cast("t.Coroutine[t.Any, t.Any, R]", coro) + try: + runner.send(None) + except StopIteration as stop: + return t.cast("R", stop.value) + runner.close() + msg = "sync bridge: tool awaited real I/O; call it on the async surface" + raise RuntimeError(msg) + + +def synced(afn: Callable[..., Awaitable[R]]) -> Callable[..., R]: + """Derive a synchronous twin of an async tool ``afn(engine, ...)``. + + The twin takes a sync :class:`TmuxEngine`, wraps it as async, and drives the + same coroutine to completion -- so each tool's logic is written exactly once. + """ + hints = t.get_type_hints(afn) + signature = inspect.signature(afn) + + @functools.wraps(afn) + def wrapper(engine: TmuxEngine, *args: t.Any, **kwargs: t.Any) -> R: + return drive_sync(afn(SyncToAsyncEngine(engine), *args, **kwargs)) + + twin_hints = dict(hints) + twin_hints["engine"] = TmuxEngine + wrapper.__annotations__ = twin_hints + wrapper.__signature__ = signature # type: ignore[attr-defined] + return wrapper diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py new file mode 100644 index 000000000..7cd6c75c9 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -0,0 +1,168 @@ +"""Caller context: who launched this MCP server, read from its own environment. + +A tmux ``-C`` control client resolves a no-target or relative target against its +*own* cursor pane, never the pane that launched the controlling process -- so the +caller pane is knowable only from the server process's environment. A process +spawned inside a tmux pane inherits ``TMUX_PANE`` (its ``%N`` id) and ``TMUX`` +(``socket-path,server-pid,session-id``); those are fixed for the process +lifetime, so the curated tools can read them at call time and the adapter can +read them once for the server instructions -- both see the same launching pane. + +Everything here is pure (no tmux call, no fastmcp): the whole point is to *avoid* +asking tmux, which would answer for the control client instead of the caller. A +pane id is unique only within one tmux server, so :func:`is_strict_caller` +socket-scopes the comparison rather than trusting a bare ``%N``. +""" + +from __future__ import annotations + +import os +import os.path +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +@dataclass(frozen=True) +class CallerContext: + """The tmux pane/server that launched this MCP, parsed from the environment. + + Examples + -------- + >>> env = {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} + >>> c = CallerContext.from_env(env) + >>> (c.pane_id, c.socket_path, c.session_id, c.in_tmux) + ('%3', '/tmp/tmux-1000/default', '2', True) + >>> CallerContext.from_env({}).in_tmux + False + >>> CallerContext.from_env({"TMUX": "garbage"}).socket_path is None + True + >>> CallerContext.from_env({"TMUX": "/tmp/a,b/sock,1,2"}).socket_path + '/tmp/a,b/sock' + """ + + pane_id: str | None = None + socket_path: str | None = None + server_pid: str | None = None + session_id: str | None = None + in_tmux: bool = False + + @classmethod + def from_env(cls, environ: Mapping[str, str] | None = None) -> CallerContext: + """Parse the caller context from *environ* (defaults to ``os.environ``). + + Degrades gracefully: a missing ``TMUX``/``TMUX_PANE`` yields a context + with ``in_tmux=False``; a malformed ``TMUX`` (not three comma fields) + leaves the socket/pid/session ``None`` but still records the pane. The + ``pid`` and ``session`` are the final two comma fields, so the value is + split from the right -- a socket path may itself contain a comma. + """ + env = os.environ if environ is None else environ + pane = env.get("TMUX_PANE") or None + raw = env.get("TMUX") or None + socket_path = server_pid = session_id = None + if raw is not None: + parts = raw.rsplit(",", 2) + if len(parts) == 3: + socket_path, server_pid, session_id = parts + return cls( + pane_id=pane, + socket_path=socket_path, + server_pid=server_pid, + session_id=session_id, + in_tmux=pane is not None, + ) + + +def _scan_flag(args: Sequence[str], flag: str) -> str | None: + """Read a tmux connection flag's value (joined ``-Sx`` or separated ``-S x``).""" + for index, arg in enumerate(args): + if arg == flag and index + 1 < len(args): + return args[index + 1] or None + if arg.startswith(flag) and len(arg) > len(flag): + return arg[len(flag) :] + return None + + +def engine_socket(engine: t.Any) -> str | None: + """Return the socket selector an engine targets (``-S`` path / ``-L`` name). + + Prefers an explicit ``-S`` path (the most precise selector) over a ``-L`` + name. ``None`` means the engine uses the ambient ``$TMUX`` server -- the same + server as a caller running inside tmux. + + Examples + -------- + >>> import types + >>> engine_socket(types.SimpleNamespace(server_args=("-Lwork",))) + 'work' + >>> engine_socket(types.SimpleNamespace(server_args=("-S", "/tmp/x"))) + '/tmp/x' + >>> engine_socket(types.SimpleNamespace(server_args=())) is None + True + """ + args = tuple(getattr(engine, "server_args", ()) or ()) + path = _scan_flag(args, "-S") + if path is not None: + return path + return _scan_flag(args, "-L") + + +def socket_matches(socket: str | None, caller: CallerContext) -> bool: + """Whether an engine *socket* selector denotes the caller's tmux server. + + A default engine (``socket is None``) talks to the ambient ``$TMUX`` server, + which is the caller's server when the caller is inside tmux *and* its socket + is known. A ``-S`` path is realpath-compared; a ``-L`` name is resolved to its + per-user socket path (honouring ``$TMUX_TMPDIR``) and realpath-compared, so a + bare name cannot collide with an unrelated socket's basename. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> socket_matches(None, caller) + True + >>> socket_matches("/tmp/s", caller) + True + >>> socket_matches("/tmp/other", caller) + False + """ + if socket is None: + return caller.in_tmux and caller.socket_path is not None + if caller.socket_path is None: + return False + if "/" in socket: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" + expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" + return os.path.realpath(expected) == os.path.realpath(caller.socket_path) + + +def is_strict_caller( + pane_id: str | None, + socket: str | None, + caller: CallerContext, +) -> bool: + """Whether *pane_id* on an engine bound to *socket* is the caller's own pane. + + Strict: requires pane-id equality *and* a confirmed socket match, since a + pane id is unique only within one tmux server. Bare pane-id equality is + rejected to avoid a cross-server false positive. + + Examples + -------- + >>> caller = CallerContext.from_env( + ... {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} + ... ) + >>> is_strict_caller("%3", None, caller) + True + >>> is_strict_caller("%9", None, caller) + False + >>> is_strict_caller("%3", "/tmp/tmux-1000/other", caller) + False + """ + if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: + return False + return socket_matches(socket, caller) diff --git a/src/libtmux/experimental/mcp/vocabulary/_geometry.py b/src/libtmux/experimental/mcp/vocabulary/_geometry.py new file mode 100644 index 000000000..9eaeb2944 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_geometry.py @@ -0,0 +1,162 @@ +"""Pure pane-geometry helpers for directional and corner resolution. + +tmux's own ``{up-of}`` / ``{down-of}`` target tokens always pivot on the +*active* pane and vary across tmux versions, so resolving "the pane to the right +of %5" robustly means reading the layout geometry and computing the neighbour +ourselves -- the same lesson libtmux-mcp's ``select_pane``/``find_pane_by_position`` +encode. These helpers operate on the ``pane_left/top/right/bottom`` and +``pane_at_*`` fields the ``list-panes`` template already carries, so they need no +extra tmux round-trip beyond the one list. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + +#: Directions a pane neighbour can be resolved in. +Direction = t.Literal["up", "down", "left", "right"] +#: The four window corners. +Corner = t.Literal["top-left", "top-right", "bottom-left", "bottom-right"] + + +def _as_int(value: str | None) -> int: + """Parse a tmux integer format value, defaulting to ``0``.""" + if value is None or value == "": + return 0 + try: + return int(value) + except ValueError: + return 0 + + +def _as_bool(value: str | None) -> bool: + """Parse a tmux flag value (``"1"``/``"0"``/``""``).""" + return value == "1" + + +@dataclass(frozen=True) +class PaneBox: + """One pane's geometry, parsed from a ``list-panes`` format row.""" + + pane_id: str + left: int + top: int + right: int + bottom: int + at_left: bool + at_right: bool + at_top: bool + at_bottom: bool + active: bool + + @classmethod + def from_row(cls, row: Mapping[str, str]) -> PaneBox: + """Build a box from a tmux format mapping.""" + return cls( + pane_id=row.get("pane_id", ""), + left=_as_int(row.get("pane_left")), + top=_as_int(row.get("pane_top")), + right=_as_int(row.get("pane_right")), + bottom=_as_int(row.get("pane_bottom")), + at_left=_as_bool(row.get("pane_at_left")), + at_right=_as_bool(row.get("pane_at_right")), + at_top=_as_bool(row.get("pane_at_top")), + at_bottom=_as_bool(row.get("pane_at_bottom")), + active=_as_bool(row.get("pane_active")), + ) + + +def parse_boxes(rows: Sequence[Mapping[str, str]]) -> list[PaneBox]: + """Parse ``list-panes`` rows into geometry boxes.""" + return [PaneBox.from_row(row) for row in rows] + + +def _overlap(a0: int, a1: int, b0: int, b1: int) -> int: + """Inclusive 1-D overlap length of ``[a0, a1]`` and ``[b0, b1]``.""" + return max(0, min(a1, b1) - max(a0, b0) + 1) + + +def neighbor( + boxes: Sequence[PaneBox], + origin_id: str, + direction: Direction, +) -> str | None: + """Return the id of the pane adjacent to *origin_id* in *direction*. + + Picks the nearest pane on the requested side that shares a perpendicular + overlap with the origin; ``None`` when the origin is unknown or has no + neighbour that way. + + Examples + -------- + Two side-by-side panes -- the right neighbour of the left pane is the right + one, and the left pane has no neighbour above it: + + >>> rows = [ + ... {"pane_id": "%1", "pane_left": "0", "pane_top": "0", + ... "pane_right": "39", "pane_bottom": "23"}, + ... {"pane_id": "%2", "pane_left": "41", "pane_top": "0", + ... "pane_right": "80", "pane_bottom": "23"}, + ... ] + >>> boxes = parse_boxes(rows) + >>> neighbor(boxes, "%1", "right") + '%2' + >>> neighbor(boxes, "%1", "up") is None + True + """ + origin = next((b for b in boxes if b.pane_id == origin_id), None) + if origin is None: + return None + ranked: list[tuple[int, int, str]] = [] + for box in boxes: + if box.pane_id == origin_id: + continue + vspan = _overlap(box.top, box.bottom, origin.top, origin.bottom) + hspan = _overlap(box.left, box.right, origin.left, origin.right) + if direction == "right" and box.left > origin.right and vspan: + ranked.append((box.left, box.top, box.pane_id)) + elif direction == "left" and box.right < origin.left and vspan: + ranked.append((-box.right, box.top, box.pane_id)) + elif direction == "down" and box.top > origin.bottom and hspan: + ranked.append((box.top, box.left, box.pane_id)) + elif direction == "up" and box.bottom < origin.top and hspan: + ranked.append((-box.bottom, box.left, box.pane_id)) + if not ranked: + return None + ranked.sort() + return ranked[0][2] + + +def corner_pane(boxes: Sequence[PaneBox], corner: Corner) -> str | None: + """Return the id of the pane occupying *corner* of the window. + + Composes the two ``pane_at_*`` edge predicates; ties (e.g. a single pane + touching every edge) break toward the visually innermost pane. + + Examples + -------- + >>> rows = [ + ... {"pane_id": "%1", "pane_left": "0", "pane_top": "0", + ... "pane_at_left": "1", "pane_at_top": "1", "pane_at_right": "0", + ... "pane_at_bottom": "1"}, + ... {"pane_id": "%2", "pane_left": "41", "pane_top": "0", + ... "pane_at_left": "0", "pane_at_top": "1", "pane_at_right": "1", + ... "pane_at_bottom": "1"}, + ... ] + >>> corner_pane(parse_boxes(rows), "top-right") + '%2' + """ + vertical, horizontal = corner.split("-") + matches = [ + box + for box in boxes + if getattr(box, f"at_{vertical}") and getattr(box, f"at_{horizontal}") + ] + if not matches: + return None + matches.sort(key=lambda b: b.left + b.top, reverse=True) + return matches[0].pane_id diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py new file mode 100644 index 000000000..87872daba --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -0,0 +1,198 @@ +"""Shared async resolution helpers for the curated vocabulary. + +The pane-scoped tools need to turn a polymorphic target into a concrete id, find +the window scoping a target, and read a window's panes -- small async steps the +pane/window modules share. Kept here so each category module stays focused on its +verbs. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + engine_socket, + socket_matches, +) +from libtmux.experimental.ops import DisplayMessage, ListPanes, SelectPane, arun +from libtmux.experimental.ops._types import PaneId, Special, Target + +#: Relative directional special tokens that resolve against the control client, +#: not the caller -- rejected with a hint by :func:`reject_relative_special`. +_RELATIVE_SPECIALS = frozenset({"up-of", "down-of", "left-of", "right-of"}) + +#: tmux ``select-pane`` direction flags for the four geometric directions. +DIR_FLAG: dict[str, t.Literal["U", "D", "L", "R"]] = { + "up": "U", + "down": "D", + "left": "L", + "right": "R", +} + + +def opt_target(target: str | Target | None) -> Target | None: + """Resolve an optional target, preserving ``None``.""" + return None if target is None else resolve_target(target) + + +async def pane_id( + engine: AsyncTmuxEngine, + target: str | Target, + version: str | None, +) -> str: + """Resolve *target* to a concrete pane id (``%N``).""" + resolved = resolve_target(target) + if isinstance(resolved, PaneId): + return resolved.value + result = await arun( + DisplayMessage(target=resolved, message="#{pane_id}"), + engine, + version=version, + ) + result.raise_for_status() + return result.text.strip() + + +async def window_id( + engine: AsyncTmuxEngine, + target: str | Target | None, + version: str | None, +) -> str: + """Resolve the window id for *target* (or the active window when ``None``).""" + op = ( + DisplayMessage(message="#{window_id}") + if target is None + else DisplayMessage(target=resolve_target(target), message="#{window_id}") + ) + result = await arun(op, engine, version=version) + result.raise_for_status() + return result.text.strip() + + +async def window_rows( + engine: AsyncTmuxEngine, + window: str, + version: str | None, +) -> list[t.Mapping[str, str]]: + """Return the ``list-panes`` rows belonging to *window*, in tmux order.""" + result = await arun(ListPanes(all_panes=True), engine, version=version) + result.raise_for_status() + return [row for row in result.rows if row.get("window_id") == window] + + +async def run_select( + engine: AsyncTmuxEngine, + op: SelectPane, + version: str | None, +) -> None: + """Run a ``select-pane`` op and raise on failure.""" + (await arun(op, engine, version=version)).raise_for_status() + + +async def select_directional( + engine: AsyncTmuxEngine, + target: str | Target | None, + flag: t.Literal["U", "D", "L", "R"], + version: str | None, +) -> None: + """Move the selection one pane in a tmux direction.""" + await run_select( + engine, SelectPane(target=opt_target(target), direction=flag), version + ) + + +async def select_step( + engine: AsyncTmuxEngine, + target: str | Target | None, + direction: t.Literal["next", "previous"], + version: str | None, +) -> None: + """Select the next/previous pane by absolute id (tmux-version robust).""" + rows = await window_rows(engine, await window_id(engine, target, version), version) + if not rows: + return + ids = [row.get("pane_id", "") for row in rows] + active = next( + (row.get("pane_id", "") for row in rows if row.get("pane_active") == "1"), + ids[0], + ) + step = 1 if direction == "next" else -1 + target_id = ids[(ids.index(active) + step) % len(ids)] + await run_select(engine, SelectPane(target=PaneId(target_id)), version) + + +async def active_pane_id( + engine: AsyncTmuxEngine, + target: str | Target | None, + version: str | None, +) -> str | None: + """Return the active pane id of the window scoping *target*.""" + rows = await window_rows(engine, await window_id(engine, target, version), version) + for row in rows: + if row.get("pane_active") == "1": + return row.get("pane_id") + return rows[0].get("pane_id") if rows else None + + +async def resolve_origin( + engine: AsyncTmuxEngine, + origin: str | Target | None, + version: str | None, +) -> str: + """Resolve a caller-relative origin to a concrete pane id. + + An explicit *origin* is resolved as a target; ``origin=None`` means the + caller's own pane (from the server's environment), socket-scoped exactly + like :func:`~._caller.is_strict_caller` because a ``%N`` is unique only + within one server. When there is no trustworthy caller pane -- the server is + not inside tmux, or its pane belongs to a different server than this engine + targets -- this raises rather than guessing the active pane, so the caller + must pass an explicit origin. + """ + if origin is not None: + return await pane_id(engine, origin, version) + caller = CallerContext.from_env() + if caller.pane_id and socket_matches(engine_socket(engine), caller): + return caller.pane_id + raise_target_hint( + "no caller pane is available (this MCP is not inside the engine's tmux " + "server); pass an explicit origin pane id (e.g. %3) -- list_panes shows " + "the current panes", + ) + + +def raise_target_hint(message: str) -> t.NoReturn: + """Raise a user-facing tool error (``ToolError`` when fastmcp is present). + + Falls back to :class:`ValueError` so the guard is testable on the sync + surface without the ``mcp`` extra installed. + """ + try: + from fastmcp.exceptions import ToolError + except ImportError: + raise ValueError(message) from None + raise ToolError(message) + + +def reject_relative_special(resolved: Target | None) -> None: + """Raise a targeted hint if *resolved* is a relative directional special. + + ``{up-of}`` / ``{down-of}`` / ``{left-of}`` / ``{right-of}`` resolve against + this MCP's own control-mode client, not the caller's pane, so passing one to + a capture/grep/send tool silently targets the wrong pane. Anchor specials + (``{marked}`` / ``{last}`` / ``{mouse}``) are left untouched. + """ + if ( + isinstance(resolved, Special) + and resolved.token.strip("{}").lower() in _RELATIVE_SPECIALS + ): + raise_target_hint( + f"relative special target {resolved.token} resolves against this MCP's " + "control-mode client, not your pane; resolve_relative_pane(direction=...) " + "and pass the returned %N to your capture/grep/send tool, or use the " + "composed capture_relative_pane / grep_relative_pane (origin defaults to " + "your pane)", + ) diff --git a/src/libtmux/experimental/mcp/vocabulary/_results.py b/src/libtmux/experimental/mcp/vocabulary/_results.py new file mode 100644 index 000000000..8ef74f516 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_results.py @@ -0,0 +1,106 @@ +"""Small, typed result values returned by the curated vocabulary. + +Each curated tool returns one of these frozen dataclasses exposing just the +ids/names/lines a caller cares about -- never a live ORM object and never the raw +:class:`~libtmux.experimental.ops.results.Result`. They serialize trivially +(plain scalars and tuples), which is what the MCP edge hands back to an agent. +""" + +from __future__ import annotations + +import collections.abc +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SessionResult: + """A created session: its id, name, and captured first window/pane ids.""" + + session_id: str + name: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class WindowResult: + """A created window: its id, name, and captured first pane id.""" + + window_id: str + name: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class PaneResult: + """A created pane: its id.""" + + pane_id: str + + +@dataclass(frozen=True) +class PaneRef: + """A resolved pane id (or ``None`` when no pane matched the query).""" + + pane_id: str | None + + +@dataclass(frozen=True) +class PaneCapture: + """Captured pane contents.""" + + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class Listing: + """A list query result: one mapping (tmux format row) per object.""" + + rows: tuple[collections.abc.Mapping[str, str], ...] + + +@dataclass(frozen=True) +class OptionMap: + """Parsed ``show-options`` output: ``name -> value`` pairs.""" + + options: collections.abc.Mapping[str, str] + + +@dataclass(frozen=True) +class MessageText: + """The formatted text of a ``display-message -p`` query.""" + + text: str + + +@dataclass(frozen=True) +class BufferText: + """The contents of a paste buffer (``show-buffer``).""" + + text: str + + +@dataclass(frozen=True) +class RawResult: + """The raw outcome of a passthrough ``run_tmux`` invocation.""" + + ok: bool + returncode: int + stdout: tuple[str, ...] + stderr: tuple[str, ...] + + +@dataclass(frozen=True) +class PaneMatch: + """One pane whose terminal text matched a search, with its caller flag.""" + + pane_id: str + is_caller: bool + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class PaneSearch: + """The panes whose scrollback matched a ``search_panes`` query.""" + + matches: tuple[PaneMatch, ...] diff --git a/src/libtmux/experimental/mcp/vocabulary/buffer.py b/src/libtmux/experimental/mcp/vocabulary/buffer.py new file mode 100644 index 000000000..1f18307ed --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/buffer.py @@ -0,0 +1,66 @@ +"""Paste-buffer vocabulary: set, show, paste.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import BufferText +from libtmux.experimental.ops import PasteBuffer, SetBuffer, ShowBuffer, arun +from libtmux.experimental.ops._types import Target + + +async def aset_buffer( + engine: AsyncTmuxEngine, + data: str, + *, + buffer_name: str | None = None, + version: str | None = None, +) -> None: + """Set a paste buffer's contents (``set-buffer``).""" + ( + await arun( + SetBuffer(data=data, buffer_name=buffer_name), + engine, + version=version, + ) + ).raise_for_status() + + +async def ashow_buffer( + engine: AsyncTmuxEngine, + *, + buffer_name: str | None = None, + version: str | None = None, +) -> BufferText: + """Return a paste buffer's contents (``show-buffer``).""" + result = await arun(ShowBuffer(buffer_name=buffer_name), engine, version=version) + result.raise_for_status() + return BufferText(text=result.text) + + +async def apaste_buffer( + engine: AsyncTmuxEngine, + target: str | Target, + *, + buffer_name: str | None = None, + delete: bool = False, + version: str | None = None, +) -> None: + """Paste a buffer into a pane (``paste-buffer``).""" + ( + await arun( + PasteBuffer( + target=resolve_target(target), + buffer_name=buffer_name, + delete=delete, + ), + engine, + version=version, + ) + ).raise_for_status() + + +set_buffer = synced(aset_buffer) +show_buffer = synced(ashow_buffer) +paste_buffer = synced(apaste_buffer) diff --git a/src/libtmux/experimental/mcp/vocabulary/option.py b/src/libtmux/experimental/mcp/vocabulary/option.py new file mode 100644 index 000000000..52f4787a7 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/option.py @@ -0,0 +1,73 @@ +"""Option vocabulary: show and set tmux options.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import OptionMap +from libtmux.experimental.ops import SetOption, ShowOptions, arun +from libtmux.experimental.ops._types import Target + + +def _opt(target: str | Target | None) -> Target | None: + """Resolve an optional target, preserving ``None``.""" + return None if target is None else resolve_target(target) + + +async def ashow_options( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + global_: bool = False, + server: bool = False, + window: bool = False, + version: str | None = None, +) -> OptionMap: + """Show tmux options as ``name -> value`` pairs (``show-options``).""" + result = await arun( + ShowOptions( + target=_opt(target), + global_=global_, + server=server, + window=window, + ), + engine, + version=version, + ) + result.raise_for_status() + return OptionMap(options=result.options) + + +async def aset_option( + engine: AsyncTmuxEngine, + option: str, + value: str | None = None, + target: str | Target | None = None, + *, + global_: bool = False, + server: bool = False, + window: bool = False, + unset: bool = False, + version: str | None = None, +) -> None: + """Set (or unset) a tmux option (``set-option``).""" + ( + await arun( + SetOption( + target=_opt(target), + option=option, + value=value, + global_=global_, + server=server, + window=window, + unset=unset, + ), + engine, + version=version, + ) + ).raise_for_status() + + +show_options = synced(ashow_options) +set_option = synced(aset_option) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py new file mode 100644 index 000000000..69bca9321 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -0,0 +1,599 @@ +"""Pane-scope vocabulary: split, send, capture, resize, swap/join/break, select. + +Beyond thin op wrappers, this module hosts the composed, caller-aware +conveniences an agent reaches for that raw tmux makes awkward: +``capture_active_pane`` (no target), ``grep_pane`` (capture + filter, since tmux +has no server-side grep), ``search_panes`` ("which pane shows X?"), and the +geometry-resolved ``resolve_relative_pane`` / ``capture_relative_pane`` / +``grep_relative_pane`` / ``find_pane_by_position`` / directional ``select_pane``. +The relative tools resolve layout geometry to a concrete ``%N`` (robust across +tmux versions) and default their origin to the *caller's* pane; every +single-target tool that could act on the wrong pane rejects a relative special +target (``{up-of}`` …) with a hint, because those resolve against this MCP's +control client, not the caller. +""" + +from __future__ import annotations + +import re +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + engine_socket, + is_strict_caller, +) +from libtmux.experimental.mcp.vocabulary._geometry import ( + Corner, + Direction, + corner_pane, + neighbor, + parse_boxes, +) +from libtmux.experimental.mcp.vocabulary._resolve import ( + DIR_FLAG, + active_pane_id, + opt_target, + raise_target_hint, + reject_relative_special, + resolve_origin, + run_select, + select_directional, + select_step, + window_id, + window_rows, +) +from libtmux.experimental.mcp.vocabulary._results import ( + Listing, + PaneCapture, + PaneMatch, + PaneRef, + PaneResult, + PaneSearch, + WindowResult, +) +from libtmux.experimental.ops import ( + BreakPane, + CapturePane, + JoinPane, + KillPane, + ListPanes, + ResizePane, + RespawnPane, + SelectPane, + SendKeys, + SplitWindow, + SwapPane, + arun, +) +from libtmux.experimental.ops._types import PaneId, Target + +#: Default ceiling on the panes ``search_panes`` captures, to bound fan-out cost. +_SEARCH_PANE_CAP = 200 + + +def _compile(pattern: str, *, ignore_case: bool) -> re.Pattern[str]: + """Compile a user-supplied regex, routing a bad pattern to a tool hint.""" + try: + return re.compile(pattern, re.IGNORECASE if ignore_case else 0) + except re.error as error: + raise_target_hint(f"invalid search pattern {pattern!r}: {error}") + + +async def asplit_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + horizontal: bool = False, + start_directory: str | None = None, + version: str | None = None, +) -> PaneResult: + """Split a pane, creating a new one (mirrors ``window.split_window``).""" + result = await arun( + SplitWindow( + target=resolve_target(target), + horizontal=horizontal, + start_directory=start_directory, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneResult(pane_id=result.new_pane_id or "") + + +async def asend_input( + engine: AsyncTmuxEngine, + target: str | Target, + keys: str, + *, + enter: bool = False, + literal: bool = False, + suppress_history: bool = False, + version: str | None = None, +) -> None: + """Send keys to a pane (mirrors ``pane.send_keys``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + SendKeys( + target=resolved, + keys=keys, + enter=enter, + literal=literal, + suppress_history=suppress_history, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def acapture_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture one pane's terminal text (mirrors ``pane.capture_pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + result = await arun( + CapturePane( + target=resolved, + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +async def acapture_active_pane( + engine: AsyncTmuxEngine, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture the active pane with no explicit target (current client). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> capture_active_pane(ConcreteEngine(capture_lines=("hi",))).lines + ('hi',) + """ + result = await arun( + CapturePane( + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +async def agrep_pane( + engine: AsyncTmuxEngine, + target: str | Target, + pattern: str, + *, + ignore_case: bool = True, + start: int | None = None, + version: str | None = None, +) -> PaneCapture: + """Search one pane's terminal text (scrollback), returning matching lines. + + tmux has no server-side grep, so this captures (joining wrapped lines so a + match is not split across a hard wrap) and filters client-side. Matching is + case-insensitive by default. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + >>> grep_pane(engine, "%1", "foo").lines + ('foo', 'foobar') + """ + resolved = resolve_target(target) + reject_relative_special(resolved) + matcher = _compile(pattern, ignore_case=ignore_case) + result = await arun( + CapturePane(target=resolved, start=start, join_wrapped=True), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=tuple(ln for ln in result.lines if matcher.search(ln))) + + +async def asearch_panes( + engine: AsyncTmuxEngine, + pattern: str, + *, + ignore_case: bool = True, + start: int | None = None, + max_panes: int = _SEARCH_PANE_CAP, + version: str | None = None, +) -> PaneSearch: + """Search every pane's terminal text; return the panes that match. + + Answers "which pane shows X?" by capturing each pane's scrollback and + filtering client-side (tmux has no cross-pane search). Each match is flagged + ``is_caller`` when it is the pane that launched this MCP. Captures are + serial, so this is bounded to the first ``max_panes`` panes; panes whose + capture fails are skipped (lenient, like the list accessors). + """ + matcher = _compile(pattern, ignore_case=ignore_case) + listing = await arun(ListPanes(all_panes=True), engine, version=version) + listing.raise_for_status() + caller = CallerContext.from_env() + socket = engine_socket(engine) + matches: list[PaneMatch] = [] + for row in listing.rows[:max_panes]: + pid = row.get("pane_id", "") + if not pid: + continue + cap = await arun( + CapturePane(target=PaneId(pid), start=start, join_wrapped=True), + engine, + version=version, + ) + if not cap.ok: + continue + lines = tuple(ln for ln in cap.lines if matcher.search(ln)) + if lines: + matches.append( + PaneMatch( + pane_id=pid, + is_caller=is_strict_caller(pid, socket, caller), + lines=lines, + ), + ) + return PaneSearch(matches=tuple(matches)) + + +async def aresize_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + width: int | None = None, + height: int | None = None, + zoom: bool = False, + version: str | None = None, +) -> None: + """Resize a pane, or toggle its zoom (``resize-pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + ResizePane(target=resolved, width=width, height=height, zoom=zoom), + engine, + version=version, + ) + ).raise_for_status() + + +async def aswap_pane( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Swap two panes (``swap-pane``: ``-s`` source, ``-t`` destination).""" + src_target = resolve_target(src) + dst_target = resolve_target(dst) + reject_relative_special(src_target) + reject_relative_special(dst_target) + ( + await arun( + SwapPane(target=dst_target, src_target=src_target), + engine, + version=version, + ) + ).raise_for_status() + + +async def ajoin_pane( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + horizontal: bool = False, + size: int | None = None, + version: str | None = None, +) -> None: + """Join a source pane into a destination window/pane (``join-pane``).""" + src_target = resolve_target(src) + dst_target = resolve_target(dst) + reject_relative_special(src_target) + reject_relative_special(dst_target) + ( + await arun( + JoinPane( + target=dst_target, + src_target=src_target, + horizontal=horizontal, + size=size, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def abreak_pane( + engine: AsyncTmuxEngine, + src: str | Target, + *, + name: str | None = None, + version: str | None = None, +) -> WindowResult: + """Break a pane out into a new window (``break-pane``); return its id.""" + src_target = resolve_target(src) + reject_relative_special(src_target) + result = await arun( + BreakPane(src_target=src_target, name=name), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult(window_id=result.new_id or "", name=name) + + +async def arespawn_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + kill: bool = False, + shell: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> None: + """Restart a pane's process in place (``respawn-pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + RespawnPane( + target=resolved, + kill=kill, + shell=shell, + start_directory=start_directory, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a pane (or all others in its window with ``others=True``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + KillPane(target=resolved, others=others), + engine, + version=version, + ) + ).raise_for_status() + + +async def alist_panes( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + all_panes: bool = False, + version: str | None = None, +) -> Listing: + """List panes (metadata), flagging the caller's own pane with ``is_caller``. + + Mirrors ``window.panes``; each row gains an ``is_caller`` field (``"1"`` for + the pane that launched this MCP, else ``"0"`` -- the same ``"1"``/``"0"`` + convention as tmux's own ``pane_active``). + """ + result = await arun(ListPanes(all_panes=all_panes), engine, version=version) + result.raise_for_status() + caller = CallerContext.from_env() + socket = engine_socket(engine) + rows = tuple( + { + **row, + "is_caller": "1" + if is_strict_caller(row.get("pane_id"), socket, caller) + else "0", + } + for row in result.rows + ) + return Listing(rows=rows) + + +async def aselect_pane( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + direction: t.Literal["up", "down", "left", "right", "last", "next", "previous"] + | None = None, + version: str | None = None, +) -> PaneRef: + """Focus a pane by id or relative *direction*; return the now-active pane. + + ``up``/``down``/``left``/``right`` use tmux's own directional select; ``last`` + re-selects the previously active pane; ``next``/``previous`` step by pane + order, computed from absolute ids to sidestep tmux-version target quirks. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> select_pane(ConcreteEngine(), "%1", direction="left") + PaneRef(pane_id=None) + """ + if direction in DIR_FLAG: + await select_directional(engine, target, DIR_FLAG[direction], version) + elif direction == "last": + await run_select( + engine, SelectPane(target=opt_target(target), last=True), version + ) + elif direction in ("next", "previous"): + await select_step(engine, target, direction, version) + elif target is not None: + resolved = resolve_target(target) + reject_relative_special(resolved) + await run_select(engine, SelectPane(target=resolved), version) + return PaneRef(pane_id=await active_pane_id(engine, target, version)) + + +async def aresolve_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None = None, + *, + version: str | None = None, +) -> PaneRef: + """Return the id of the pane *direction* of *origin* (caller pane by default). + + Resolved from layout geometry (the ``pane_left/top/right/bottom`` the list + template already carries) -- robust across tmux versions, and without moving + the active pane. ``origin=None`` means the caller's own pane (the pane that + launched this MCP), falling back to the active pane outside tmux. + """ + origin_id = await resolve_origin(engine, origin, version) + if not origin_id: + return PaneRef(pane_id=None) + window = await window_id(engine, origin_id, version) + boxes = parse_boxes(await window_rows(engine, window, version)) + return PaneRef(pane_id=neighbor(boxes, origin_id, direction)) + + +async def acapture_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None = None, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture the pane *direction* of *origin* (caller pane by default). + + Resolves the neighbour to a concrete ``%N`` first, so it never hands tmux a + relative special target. Raises a hint (naming the resolved origin) when + there is no pane that way. + """ + ref = await aresolve_relative_pane(engine, direction, origin, version=version) + if ref.pane_id is None: + await _raise_no_neighbour(engine, direction, origin, version) + return await acapture_pane( + engine, + PaneId(ref.pane_id or ""), + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + version=version, + ) + + +async def agrep_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + pattern: str, + origin: str | Target | None = None, + *, + ignore_case: bool = True, + start: int | None = None, + version: str | None = None, +) -> PaneCapture: + """Search the terminal text of the pane *direction* of *origin* (caller default). + + The one-call answer to "what does the pane above/below/beside me show?". + Resolves the neighbour to a concrete ``%N`` first; raises a hint (naming the + resolved origin) when there is no pane that way. + """ + ref = await aresolve_relative_pane(engine, direction, origin, version=version) + if ref.pane_id is None: + await _raise_no_neighbour(engine, direction, origin, version) + return await agrep_pane( + engine, + PaneId(ref.pane_id or ""), + pattern, + ignore_case=ignore_case, + start=start, + version=version, + ) + + +async def afind_pane_by_position( + engine: AsyncTmuxEngine, + corner: Corner, + target: str | Target | None = None, + *, + version: str | None = None, +) -> PaneRef: + """Return the id of the pane occupying *corner* of a window.""" + window = await window_id(engine, target, version) + boxes = parse_boxes(await window_rows(engine, window, version)) + return PaneRef(pane_id=corner_pane(boxes, corner)) + + +async def _raise_no_neighbour( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None, + version: str | None, +) -> t.NoReturn: + """Raise a no-neighbour hint naming the concrete origin pane.""" + origin_id = await resolve_origin(engine, origin, version) + where = origin_id or "the caller pane" + raise_target_hint( + f"no pane {direction} of {where}; see list_panes for the current layout", + ) + + +split_pane = synced(asplit_pane) +send_input = synced(asend_input) +capture_pane = synced(acapture_pane) +capture_active_pane = synced(acapture_active_pane) +grep_pane = synced(agrep_pane) +search_panes = synced(asearch_panes) +resize_pane = synced(aresize_pane) +swap_pane = synced(aswap_pane) +join_pane = synced(ajoin_pane) +break_pane = synced(abreak_pane) +respawn_pane = synced(arespawn_pane) +kill_pane = synced(akill_pane) +list_panes = synced(alist_panes) +select_pane = synced(aselect_pane) +resolve_relative_pane = synced(aresolve_relative_pane) +capture_relative_pane = synced(acapture_relative_pane) +grep_relative_pane = synced(agrep_relative_pane) +find_pane_by_position = synced(afind_pane_by_position) diff --git a/src/libtmux/experimental/mcp/vocabulary/server.py b/src/libtmux/experimental/mcp/vocabulary/server.py new file mode 100644 index 000000000..d4f166746 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/server.py @@ -0,0 +1,71 @@ +"""Server-scope vocabulary: list clients, display-message, and the raw escape hatch.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine, CommandRequest +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import Listing, MessageText, RawResult +from libtmux.experimental.ops import DisplayMessage, ListClients, arun +from libtmux.experimental.ops._types import Target + + +async def alist_clients( + engine: AsyncTmuxEngine, + *, + version: str | None = None, +) -> Listing: + """List attached clients (``list-clients``).""" + result = await arun(ListClients(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def adisplay_message( + engine: AsyncTmuxEngine, + target: str | Target, + message: str, + *, + version: str | None = None, +) -> MessageText: + """Expand a tmux format string against *target* (``display-message -p``).""" + result = await arun( + DisplayMessage(target=resolve_target(target), message=message), + engine, + version=version, + ) + result.raise_for_status() + return MessageText(text=result.text) + + +async def arun_tmux( + engine: AsyncTmuxEngine, + args: list[str], + *, + version: str | None = None, +) -> RawResult: + """Run an arbitrary tmux command (the guarded raw escape hatch). + + Returns the structured outcome without raising on a tmux-side failure -- a + nonzero exit or stderr is reported in :class:`~._results.RawResult`. This is + deliberately *not* read-only; servers exposed on a network transport should + gate it more strictly than local ones. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> run_tmux(ConcreteEngine(), ["list-sessions"]).ok + True + """ + raw = await engine.run(CommandRequest.from_args(*args)) + return RawResult( + ok=raw.returncode == 0 and not raw.stderr, + returncode=raw.returncode, + stdout=tuple(raw.stdout), + stderr=tuple(raw.stderr), + ) + + +list_clients = synced(alist_clients) +display_message = synced(adisplay_message) +run_tmux = synced(arun_tmux) diff --git a/src/libtmux/experimental/mcp/vocabulary/session.py b/src/libtmux/experimental/mcp/vocabulary/session.py new file mode 100644 index 000000000..419233265 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/session.py @@ -0,0 +1,134 @@ +"""Session-scope vocabulary: create, rename, kill, list, existence. + +Each tool is written once as an ``async def`` over an +:class:`~libtmux.experimental.engines.base.AsyncTmuxEngine`; the public sync name +is a :func:`~._bridge.synced` twin. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import Listing, SessionResult +from libtmux.experimental.ops import ( + HasSession, + KillSession, + ListSessions, + NewSession, + RenameSession, + arun, +) +from libtmux.experimental.ops._types import Target + + +async def acreate_session( + engine: AsyncTmuxEngine, + *, + name: str | None = None, + start_directory: str | None = None, + environment: t.Mapping[str, str] | None = None, + width: int | None = None, + height: int | None = None, + version: str | None = None, +) -> SessionResult: + """Create a detached session (mirrors ``server.new_session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> r = create_session(ConcreteEngine(), name="work") + >>> (r.session_id, r.name, r.first_pane_id) + ('$1', 'work', '%1') + """ + result = await arun( + NewSession( + session_name=name, + start_directory=start_directory, + environment=environment, + width=width, + height=height, + capture_panes=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return SessionResult( + session_id=result.new_id or "", + name=name, + first_window_id=result.first_window_id, + first_pane_id=result.first_pane_id, + ) + + +async def arename_session( + engine: AsyncTmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a session (mirrors ``session.rename_session``).""" + ( + await arun( + RenameSession(target=resolve_target(target), name=name), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_session( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Kill a session (mirrors ``session.kill``).""" + ( + await arun(KillSession(target=resolve_target(target)), engine, version=version) + ).raise_for_status() + + +async def alist_sessions( + engine: AsyncTmuxEngine, + *, + version: str | None = None, +) -> Listing: + """List the server's sessions (mirrors ``server.sessions``).""" + result = await arun(ListSessions(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def ahas_session( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> bool: + """Return whether a session exists (``has-session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> has_session(ConcreteEngine(), "$1") + True + """ + result = await arun( + HasSession(target=resolve_target(target)), + engine, + version=version, + ) + result.raise_for_status() + return result.exists + + +create_session = synced(acreate_session) +rename_session = synced(arename_session) +kill_session = synced(akill_session) +list_sessions = synced(alist_sessions) +has_session = synced(ahas_session) diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py new file mode 100644 index 000000000..c9d856b53 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -0,0 +1,182 @@ +"""Window-scope vocabulary: create, rename, select, move, swap, kill, list, layout.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import Listing, WindowResult +from libtmux.experimental.ops import ( + KillWindow, + ListWindows, + MoveWindow, + NewWindow, + RenameWindow, + SelectLayout, + SelectWindow, + SwapWindow, + arun, +) +from libtmux.experimental.ops._types import Target + + +async def acreate_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + name: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> WindowResult: + """Create a window in a session (mirrors ``session.new_window``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> w = create_window(ConcreteEngine(), "$1", name="logs") + >>> w.window_id.startswith("@"), w.name + (True, 'logs') + """ + result = await arun( + NewWindow( + target=resolve_target(target), + name=name, + start_directory=start_directory, + capture_pane=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult( + window_id=result.new_id or "", + name=name, + first_pane_id=result.first_pane_id, + ) + + +async def arename_window( + engine: AsyncTmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a window (mirrors ``window.rename_window``).""" + ( + await arun( + RenameWindow(target=resolve_target(target), name=name), + engine, + version=version, + ) + ).raise_for_status() + + +async def aselect_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Make a window active (mirrors ``window.select_window``).""" + ( + await arun(SelectWindow(target=resolve_target(target)), engine, version=version) + ).raise_for_status() + + +async def amove_window( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Move a window to a new index/session (``move-window``).""" + ( + await arun( + MoveWindow(target=resolve_target(dst), src_target=resolve_target(src)), + engine, + version=version, + ) + ).raise_for_status() + + +async def aswap_window( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Swap two windows (``swap-window``).""" + ( + await arun( + SwapWindow(target=resolve_target(dst), src_target=resolve_target(src)), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a window (or all others in its session with ``others=True``).""" + ( + await arun( + KillWindow(target=resolve_target(target), others=others), + engine, + version=version, + ) + ).raise_for_status() + + +async def alist_windows( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + all_windows: bool = False, + version: str | None = None, +) -> Listing: + """List windows of a session, or all windows (mirrors ``session.windows``).""" + result = await arun( + ListWindows( + target=None if target is None else resolve_target(target), + all_windows=all_windows, + ), + engine, + version=version, + ) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def aselect_layout( + engine: AsyncTmuxEngine, + target: str | Target, + *, + layout: str | None = None, + version: str | None = None, +) -> None: + """Apply a layout to a window (mirrors ``window.select_layout``).""" + ( + await arun( + SelectLayout(target=resolve_target(target), layout=layout), + engine, + version=version, + ) + ).raise_for_status() + + +create_window = synced(acreate_window) +rename_window = synced(arename_window) +select_window = synced(aselect_window) +move_window = synced(amove_window) +swap_window = synced(aswap_window) +kill_window = synced(akill_window) +list_windows = synced(alist_windows) +select_layout = synced(aselect_layout) diff --git a/tests/experimental/mcp/test_adapter_async.py b/tests/experimental/mcp/test_adapter_async.py new file mode 100644 index 000000000..08a7b6904 --- /dev/null +++ b/tests/experimental/mcp/test_adapter_async.py @@ -0,0 +1,93 @@ +"""The async-first FastMCP adapter -- awaited tools, per-op, and plan tiers. + +Exercised offline via an in-process FastMCP client over a sync ``ConcreteEngine`` +wrapped into the async protocol, so the async registration path is validated with +no tmux. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + +fastmcp = pytest.importorskip("fastmcp") + + +def _async_server(**kwargs: t.Any) -> t.Any: + """Build an async server over a wrapped in-memory engine.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + return build_async_server( + SyncToAsyncEngine(ConcreteEngine()), events="off", **kwargs + ) + + +def test_async_server_exposes_curated_and_conveniences() -> None: + """The async server surfaces the curated verbs plus the new conveniences.""" + + async def main() -> set[str]: + async with fastmcp.Client(_async_server()) as client: + return {tool.name for tool in await client.list_tools()} + + names = asyncio.run(main()) + expected = { + "create_session", + "grep_pane", + "capture_active_pane", + "resolve_relative_pane", + "find_pane_by_position", + "select_pane", + "resize_pane", + "run_tmux", + "list_clients", + "has_session", + } + assert expected <= names + # The per-op surface is hidden by default. + assert not any(name.startswith("op_") for name in names) + + +def test_async_tool_call_returns_typed_data() -> None: + """An awaited curated tool returns its typed result over the client.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server()) as client: + result = await client.call_tool("create_session", {"name": "dev"}) + raw = await client.call_tool("run_tmux", {"args": ["list-sessions"]}) + return result.data, raw.data + + session, raw = asyncio.run(main()) + assert session.session_id == "$1" + assert raw.ok is True + + +def test_async_per_op_dispatch() -> None: + """A hidden per-op tool dispatches via the async run path.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server(expose_operations=True)) as client: + result = await client.call_tool("op_list_sessions", {}) + return result.data + + data = asyncio.run(main()) + assert data["status"] == "complete" + + +def test_async_plan_preview() -> None: + """The pure preview_plan tool is registered on the async server.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server()) as client: + result = await client.call_tool( + "preview_plan", + {"operations": [{"kind": "start_server"}]}, + ) + return result.data + + data = asyncio.run(main()) + assert data["ok"] is True diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py new file mode 100644 index 000000000..ce208774c --- /dev/null +++ b/tests/experimental/mcp/test_caller.py @@ -0,0 +1,193 @@ +"""Caller context: environment parsing, strict socket scoping, and surfacing. + +Pure tests cover :class:`CallerContext.from_env` and the strict ``is_caller`` +comparator over literal env mappings; in-process FastMCP ``Client`` tests cover +the ``get_caller_context`` tool, the ``is_caller`` instruction sentence, and (live) +the ``is_caller`` row flag against a real tmux server. No pytest-asyncio. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import create_session, kill_session, list_panes +from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + engine_socket, + is_strict_caller, +) +from libtmux.experimental.mcp.vocabulary._resolve import resolve_origin + +fastmcp = pytest.importorskip("fastmcp") +from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +# --------------------------------------------------------------------------- # +# CallerContext.from_env (pure) +# --------------------------------------------------------------------------- # +def test_from_env_inside_tmux() -> None: + """A full TMUX/TMUX_PANE pair parses into a populated context.""" + ctx = CallerContext.from_env( + {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"}, + ) + assert ctx.in_tmux + assert ctx.pane_id == "%3" + assert ctx.socket_path == "/tmp/tmux-1000/default" + assert ctx.server_pid == "42" + assert ctx.session_id == "2" + + +def test_from_env_outside_tmux() -> None: + """No TMUX/TMUX_PANE yields a context with in_tmux False.""" + assert CallerContext.from_env({}).in_tmux is False + + +def test_from_env_malformed_tmux() -> None: + """A malformed TMUX keeps the pane but leaves socket/pid/session None.""" + ctx = CallerContext.from_env({"TMUX_PANE": "%5", "TMUX": "garbage"}) + assert ctx.pane_id == "%5" + assert ctx.in_tmux is True + assert ctx.socket_path is None + + +# --------------------------------------------------------------------------- # +# Strict caller / engine socket (pure) +# --------------------------------------------------------------------------- # +def test_is_strict_caller_socket_scoped() -> None: + """is_caller requires pane equality and a socket match.""" + caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/a,1,2"}) + assert is_strict_caller("%3", None, caller) is True # default engine, same server + assert is_strict_caller("%3", "/tmp/a", caller) is True # same socket path + assert is_strict_caller("%3", "/tmp/b", caller) is False # cross-socket + assert is_strict_caller("%9", None, caller) is False # different pane + + +def test_is_strict_caller_outside_tmux() -> None: + """Nothing is the caller when the server is not inside tmux.""" + assert is_strict_caller("%1", None, CallerContext.from_env({})) is False + + +def test_engine_socket_parses_server_args() -> None: + """engine_socket reads -L name / -S path, else None for the default socket.""" + + class Named: + server_args = ("-Lwork",) + + class Pathed: + server_args = ("-S/tmp/x",) + + class Default: + server_args = () + + assert engine_socket(Named()) == "work" + assert engine_socket(Pathed()) == "/tmp/x" + assert engine_socket(Default()) is None + + +# --------------------------------------------------------------------------- # +# Surfacing via the server (in-process client) +# --------------------------------------------------------------------------- # +def test_get_caller_context_tool(monkeypatch: pytest.MonkeyPatch) -> None: + """get_caller_context returns the context read from the server's env.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%7") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return (await client.call_tool("get_caller_context", {})).data + + data = asyncio.run(main()) + assert data.pane_id == "%7" + assert data.in_tmux is True + + +def test_instructions_include_caller(monkeypatch: pytest.MonkeyPatch) -> None: + """The instructions name the caller pane when the server is inside tmux.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%7") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + assert "%7" in (server.instructions or "") + assert "is_caller" in (server.instructions or "") + + +def test_instructions_outside_tmux(monkeypatch: pytest.MonkeyPatch) -> None: + """The instructions say so when the server is not inside tmux.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + assert "not running inside a tmux pane" in (server.instructions or "") + + +def test_is_caller_row_flag_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """list_panes flags the caller's own pane when env points at it.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="callerlive") + try: + pane = created.first_pane_id + assert pane is not None + socket = engine_socket(engine) or "" + monkeypatch.setenv("TMUX_PANE", pane) + monkeypatch.setenv("TMUX", f"{socket},0,0") + flagged = [ + row["pane_id"] + for row in list_panes(engine).rows + if row.get("is_caller") == "1" + ] + assert pane in flagged + finally: + kill_session(engine, created.session_id) + + +# --------------------------------------------------------------------------- # +# resolve_origin caller-default is socket-scoped (the behavioural path) +# --------------------------------------------------------------------------- # +def test_resolve_origin_same_server_uses_caller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """resolve_origin trusts the caller pane when the engine shares its server.""" + monkeypatch.setenv("TMUX_PANE", "%3") + monkeypatch.setenv("TMUX", "/tmp/a,1,2") + engine = SyncToAsyncEngine(ConcreteEngine()) # default socket -> ambient server + + async def main() -> str: + return await resolve_origin(engine, None, None) + + assert asyncio.run(main()) == "%3" + + +def test_resolve_origin_cross_server_requires_explicit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """resolve_origin requires an explicit origin for a cross-server caller.""" + monkeypatch.setenv("TMUX_PANE", "%3") + monkeypatch.setenv("TMUX", "/tmp/a,1,2") + + class CrossServer(SyncToAsyncEngine): + server_args = ("-S", "/tmp/b") # a different socket than the caller's + + engine = CrossServer(ConcreteEngine()) + + async def main() -> str: + return await resolve_origin(engine, None, None) + + # Cross-server: the env %3 is refused; the caller must name an origin. + with pytest.raises(ToolError, match="explicit origin"): + asyncio.run(main()) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py new file mode 100644 index 000000000..4f8a7ef0e --- /dev/null +++ b/tests/experimental/mcp/test_events.py @@ -0,0 +1,140 @@ +"""The live event stream tools -- push, pull, and the registration gate. + +Driven offline against a fake engine that yields a fixed notification sequence, +so the push/pull mechanics are exercised without a real tmux ``-C`` connection. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines.async_control_mode import ControlNotification +from libtmux.experimental.engines.base import CommandResult + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator, Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class FakeStreamEngine: + """An async engine that replays a fixed notification stream.""" + + def __init__(self, raw: tuple[bytes, ...]) -> None: + self._raw = raw + + async def run(self, request: CommandRequest) -> CommandResult: + """Acknowledge any command.""" + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield the fixed notification sequence.""" + for raw in self._raw: + yield ControlNotification.parse(raw) + + +_STREAM = (b"%window-add @3", b"%output %1 hi", b"%window-close @3") + + +def _tool_names(server: t.Any) -> set[str]: + """Return the visible tool names of *server* (via an in-process client).""" + + async def main() -> set[str]: + async with fastmcp.Client(server) as client: + return {tool.name for tool in await client.list_tools()} + + return asyncio.run(main()) + + +def test_push_collects_filtered_events() -> None: + """watch_events streams and returns only the requested notification kinds.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "watch_events", + { + "kinds": ["window-add", "window-close"], + "max_events": 2, + "timeout": 2.0, + }, + ) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert data["count"] == 2 + assert [event["kind"] for event in data["events"]] == ["window-add", "window-close"] + + +def test_pull_buffers_events() -> None: + """poll_events drains the background ring buffer with a cursor.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="pull", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + await client.call_tool("poll_events", {"since": 0}) # start the drainer + await asyncio.sleep(0.05) + result = await client.call_tool("poll_events", {"since": 0}) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert len(data["events"]) == 3 + assert data["cursor"] == 3 + + +def test_both_registers_push_and_pull() -> None: + """events='both' exposes both mechanisms.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="both", + include_operations=False, + include_plan_tools=False, + ) + names = _tool_names(server) + assert {"watch_events", "poll_events"} <= names + + +def test_no_event_tools_without_a_stream() -> None: + """A non-streaming engine registers no event tools, even when asked.""" + from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + + server = build_async_server( + SyncToAsyncEngine(ConcreteEngine()), + events="both", + include_operations=False, + include_plan_tools=False, + ) + names = _tool_names(server) + assert "watch_events" not in names + assert "poll_events" not in names diff --git a/tests/experimental/mcp/test_relative_special_guard.py b/tests/experimental/mcp/test_relative_special_guard.py new file mode 100644 index 000000000..152b68a51 --- /dev/null +++ b/tests/experimental/mcp/test_relative_special_guard.py @@ -0,0 +1,119 @@ +"""The relative-special-target guard and the composed relative tools. + +``capture_pane`` / ``grep_pane`` / ``send_input`` must reject a directional +special target (``{up-of}`` …) with a hint -- those resolve against this MCP's +control client, not the caller. Anchor specials (``{marked}`` / ``{last}``) must +still pass through. The composed ``capture_relative_pane`` resolves the neighbour +to a concrete ``%N`` first (live). +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import ( + break_pane, + capture_pane, + capture_relative_pane, + create_session, + grep_pane, + join_pane, + kill_pane, + kill_session, + resize_pane, + respawn_pane, + select_pane, + send_input, + split_pane, + swap_pane, +) + +fastmcp = pytest.importorskip("fastmcp") +from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@pytest.mark.parametrize("token", ["{up-of}", "{down-of}", "{left-of}", "{right-of}"]) +def test_grep_rejects_relative_special(token: str) -> None: + """grep_pane with a directional special target raises a targeted hint.""" + with pytest.raises(ToolError, match="control-mode client"): + grep_pane(ConcreteEngine(capture_lines=("x",)), token, "x") + + +def test_capture_rejects_relative_special() -> None: + """capture_pane rejects a directional special target.""" + with pytest.raises(ToolError, match="control-mode client"): + capture_pane(ConcreteEngine(), "{down-of}") + + +def test_send_rejects_relative_special() -> None: + """send_input rejects a directional special target.""" + with pytest.raises(ToolError, match="control-mode client"): + send_input(ConcreteEngine(), "{left-of}", "ls") + + +@pytest.mark.parametrize("token", ["{marked}", "{last}"]) +def test_anchor_specials_pass_through(token: str) -> None: + """Anchor special targets are not rejected (real tmux semantics).""" + engine = ConcreteEngine(capture_lines=("hi",)) + assert capture_pane(engine, token).lines == ("hi",) + + +@pytest.mark.parametrize( + "call", + [ + lambda e: kill_pane(e, "{up-of}"), + lambda e: resize_pane(e, "{up-of}", width=80), + lambda e: respawn_pane(e, "{up-of}"), + lambda e: swap_pane(e, "{up-of}", "%1"), + lambda e: swap_pane(e, "%1", "{up-of}"), + lambda e: join_pane(e, "{up-of}", "%1"), + lambda e: break_pane(e, "{up-of}"), + lambda e: select_pane(e, "{up-of}"), + ], + ids=[ + "kill", + "resize", + "respawn", + "swap_src", + "swap_dst", + "join", + "break", + "select", + ], +) +def test_mutating_tools_reject_relative_special(call: t.Any) -> None: + """Destructive/mutating pane tools reject a relative special target too.""" + with pytest.raises(ToolError, match="control-mode client"): + call(ConcreteEngine()) + + +def test_grep_pane_invalid_regex_hint() -> None: + """An invalid search regex is surfaced as a targeted hint, not a raw re.error.""" + with pytest.raises(ToolError, match="invalid search pattern"): + grep_pane(ConcreteEngine(capture_lines=("x",)), "%1", "[unclosed") + + +def test_capture_relative_pane_resolves_concrete_live(session: Session) -> None: + """capture_relative_pane resolves a neighbour to a concrete %N and captures it.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="relcap") + try: + origin = created.first_pane_id + assert origin is not None + split_pane(engine, origin, horizontal=True) + captured = None + for direction in ("left", "right"): + try: + captured = capture_relative_pane(engine, direction, origin) + break + except ToolError: + continue # no neighbour that way; try the other side + assert captured is not None # resolved a concrete pane and captured it + finally: + kill_session(engine, created.session_id) diff --git a/tests/experimental/mcp/test_vocabulary_extended.py b/tests/experimental/mcp/test_vocabulary_extended.py new file mode 100644 index 000000000..81929265c --- /dev/null +++ b/tests/experimental/mcp/test_vocabulary_extended.py @@ -0,0 +1,243 @@ +"""Extended vocabulary -- new verbs, conveniences, the async surface, the bridge. + +Pure tests run against the in-memory ``ConcreteEngine`` and the pure geometry +helpers (no tmux); live tests drive a real tmux server for the geometry-resolved +conveniences (``resolve_relative_pane`` / ``find_pane_by_position`` / directional +``select_pane``) that only mean something against a real layout. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import ( + PaneRef, + acreate_session, + agrep_pane, + capture_active_pane, + create_session, + grep_pane, + has_session, + resize_pane, + resolve_relative_pane, + run_tmux, + select_pane, +) +from libtmux.experimental.mcp.vocabulary._bridge import ( + SyncToAsyncEngine, + drive_sync, + synced, +) +from libtmux.experimental.mcp.vocabulary._geometry import ( + PaneBox, + corner_pane, + neighbor, + parse_boxes, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +# --------------------------------------------------------------------------- # +# Geometry helpers (pure) +# --------------------------------------------------------------------------- # +def _two_columns() -> list[PaneBox]: + """Build a left|right two-pane layout.""" + return parse_boxes( + [ + { + "pane_id": "%1", + "pane_left": "0", + "pane_top": "0", + "pane_right": "39", + "pane_bottom": "23", + "pane_at_left": "1", + "pane_at_top": "1", + "pane_at_bottom": "1", + "pane_at_right": "0", + }, + { + "pane_id": "%2", + "pane_left": "41", + "pane_top": "0", + "pane_right": "80", + "pane_bottom": "23", + "pane_at_left": "0", + "pane_at_top": "1", + "pane_at_bottom": "1", + "pane_at_right": "1", + }, + ], + ) + + +def test_neighbor_resolves_horizontal() -> None: + """The right neighbour of the left pane is the right pane, and vice versa.""" + boxes = _two_columns() + assert neighbor(boxes, "%1", "right") == "%2" + assert neighbor(boxes, "%2", "left") == "%1" + + +def test_neighbor_none_when_no_pane_that_way() -> None: + """A pane with no neighbour in a direction resolves to None.""" + boxes = _two_columns() + assert neighbor(boxes, "%1", "left") is None + assert neighbor(boxes, "%1", "up") is None + assert neighbor(boxes, "%9", "right") is None # unknown origin + + +def test_corner_pane_uses_edge_predicates() -> None: + """The corner finder composes the pane_at_* predicates.""" + boxes = _two_columns() + assert corner_pane(boxes, "top-left") == "%1" + assert corner_pane(boxes, "bottom-right") == "%2" + + +# --------------------------------------------------------------------------- # +# The sync bridge +# --------------------------------------------------------------------------- # +def test_synced_twin_runs_over_sync_engine() -> None: + """A synced twin drives its async source over a plain sync engine.""" + result = create_session(ConcreteEngine(), name="dev") # create_session is a twin + assert result.session_id == "$1" + + +def test_drive_sync_rejects_real_io() -> None: + """drive_sync refuses a coroutine that suspends on a real await.""" + + async def suspends() -> int: + await asyncio.sleep(0) # yields to the loop -- no loop here + return 1 + + with pytest.raises(RuntimeError, match="real I/O"): + drive_sync(suspends()) + + +def test_synced_preserves_callable() -> None: + """synced() yields a callable with the engine param retyped to sync.""" + + async def tool(engine: t.Any, value: int) -> int: + return value + + twin = synced(tool) + hints = t.get_type_hints(twin) + assert hints["engine"].__name__ == "TmuxEngine" + + +# --------------------------------------------------------------------------- # +# New verbs / conveniences (offline) +# --------------------------------------------------------------------------- # +def test_grep_pane_filters_lines() -> None: + """grep_pane returns only the captured lines matching the pattern.""" + engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + assert grep_pane(engine, "%1", "foo").lines == ("foo", "foobar") + + +def test_grep_pane_ignore_case() -> None: + """grep_pane honours the ignore_case flag.""" + engine = ConcreteEngine(capture_lines=("FOO", "bar")) + assert grep_pane(engine, "%1", "foo", ignore_case=True).lines == ("FOO",) + + +def test_capture_active_pane_needs_no_target() -> None: + """capture_active_pane captures with no explicit target.""" + engine = ConcreteEngine(capture_lines=("hello",)) + assert capture_active_pane(engine).lines == ("hello",) + + +def test_resize_and_run_tmux_offline() -> None: + """resize_pane is fire-and-forget; run_tmux returns a raw outcome.""" + engine = ConcreteEngine() + assert resize_pane(engine, "%1", width=80) is None + raw = run_tmux(engine, ["list-sessions"]) + assert raw.ok and raw.returncode == 0 + + +def test_has_session_returns_bool() -> None: + """has_session answers an existence query as a bool.""" + assert has_session(ConcreteEngine(), "$1") is True + + +def test_geometry_tools_return_paneref_offline() -> None: + """Geometry-resolved tools return a PaneRef even with nothing to resolve.""" + engine = ConcreteEngine() + assert isinstance(resolve_relative_pane(engine, "right", "%1"), PaneRef) + assert isinstance(select_pane(engine, "%1", direction="left"), PaneRef) + + +# --------------------------------------------------------------------------- # +# The async surface +# --------------------------------------------------------------------------- # +def test_async_surface_over_wrapped_engine() -> None: + """The a-prefixed tools run over an async engine (sync engine wrapped).""" + + async def main() -> tuple[str, tuple[str, ...]]: + engine = SyncToAsyncEngine(ConcreteEngine(capture_lines=("x", "y"))) + session = await acreate_session(engine, name="dev") + grep = await agrep_pane(engine, "%1", "x") + return session.session_id, grep.lines + + session_id, lines = asyncio.run(main()) + assert session_id == "$1" + assert lines == ("x",) + + +# --------------------------------------------------------------------------- # +# Live geometry conveniences +# --------------------------------------------------------------------------- # +def test_resolve_relative_pane_live(session: Session) -> None: + """resolve_relative_pane finds the adjacent pane in a real split layout.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="reltest") + try: + origin = created.first_pane_id + assert origin is not None + other = split_pane_id(engine, origin) + # Exactly one of left/right of the origin is the new pane. + found = { + resolve_relative_pane(engine, "left", origin).pane_id, + resolve_relative_pane(engine, "right", origin).pane_id, + } + assert other in found + finally: + kill(engine, created.session_id) + + +def test_find_pane_by_position_live(session: Session) -> None: + """find_pane_by_position returns a real pane occupying the corner.""" + from libtmux.experimental.mcp.vocabulary import find_pane_by_position, list_panes + + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="corner") + try: + origin = created.first_pane_id + assert origin is not None + split_pane_id(engine, origin) + ids = { + row["pane_id"] + for row in list_panes(engine).rows + if row.get("session_id") == created.session_id + } + corner = find_pane_by_position(engine, "top-left", origin).pane_id + assert corner in ids + finally: + kill(engine, created.session_id) + + +def split_pane_id(engine: SubprocessEngine, target: str) -> str: + """Split *target* horizontally and return the new pane id (test helper).""" + from libtmux.experimental.mcp.vocabulary import split_pane + + return split_pane(engine, target, horizontal=True).pane_id + + +def kill(engine: SubprocessEngine, session_id: str) -> None: + """Kill a test session (helper).""" + from libtmux.experimental.mcp.vocabulary import kill_session + + kill_session(engine, session_id) From 8299fb9e084565fbabf4f0eaa8006c66e0ac2f38 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:39:56 -0500 Subject: [PATCH 068/223] Mcp(feat): Caller discovery + self-kill guards why: The round-2 caller-awareness read only the MCP's own environment, which real launchers (agent -> uv -> python child) strip -- so the caller pane was undiscoverable and the whole surface went inert. what: - Add CallerContext.discover(): process-env -> explicit override (LIBTMUX_MCP_CALLER_PANE/TMUX) -> a bounded, same-uid Linux /proc parent-process env walk (vocabulary/_proc.py). Fail-closed (never raises), env-minimised to TMUX/TMUX_PANE, depth-capped, and injectable so it is unit-testable without /proc; records the discovery source. - Bind the default engine to the discovered caller's -S socket when no explicit override (--socket-path/--socket-name/--no-caller-socket, $LIBTMUX_SOCKET*), so a stripped-env MCP still drives the user's own tmux server instead of a fresh default one. - Add a conservative socket comparator (socket_could_match / is_conservative_caller) alongside the strict one: the strict stays on the is_caller annotation and origin resolution; the conservative, fail-safe one guards destructive ops. - Refuse self-kill: kill_pane / respawn_pane / kill_window / kill_session (and the others=True siblings) decline the pane, window, or session running this MCP, with a hint to act manually. - Thread the discovered context to the tool bodies by stashing it on the engine (read by caller_of); SyncToAsyncEngine delegates server_args / _caller_context so the sync surface sees the same identity. - Cover with /proc parser, discovery precedence, comparator, and self-kill tests (no pytest-asyncio); fix the stale resolve_relative_pane active-pane-fallback docstring. --- src/libtmux/experimental/mcp/__init__.py | 83 +++++- .../experimental/mcp/fastmcp_adapter.py | 22 +- .../experimental/mcp/vocabulary/_bridge.py | 10 + .../experimental/mcp/vocabulary/_caller.py | 240 ++++++++++++++++-- .../experimental/mcp/vocabulary/_proc.py | 87 +++++++ .../experimental/mcp/vocabulary/_resolve.py | 86 ++++++- .../experimental/mcp/vocabulary/pane.py | 45 +++- .../experimental/mcp/vocabulary/session.py | 3 + .../experimental/mcp/vocabulary/window.py | 49 +++- tests/experimental/mcp/test_caller.py | 163 +++++++++++- tests/experimental/mcp/test_proc.py | 43 ++++ 11 files changed, 791 insertions(+), 40 deletions(-) create mode 100644 src/libtmux/experimental/mcp/vocabulary/_proc.py create mode 100644 tests/experimental/mcp/test_proc.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 2861957b7..0b3f89033 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -68,6 +68,40 @@ from fastmcp import FastMCP + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + +def _socket_args( + caller: CallerContext, + *, + socket_path: str | None = None, + socket_name: str | None = None, + no_caller_socket: bool = False, +) -> tuple[str, ...]: + """Resolve tmux connection flags: explicit overrides, else the caller's socket. + + Precedence: ``--socket-path`` > ``--socket-name`` > ``$LIBTMUX_SOCKET_PATH`` > + ``$LIBTMUX_SOCKET`` > the discovered caller's socket (unless suppressed) > + none (ambient/default server). + """ + import os + + from libtmux.experimental.mcp.vocabulary._caller import caller_server_args + + if socket_path: + return ("-S", socket_path) + if socket_name: + return ("-L", socket_name) + env_path = os.environ.get("LIBTMUX_SOCKET_PATH") + if env_path: + return ("-S", env_path) + env_name = os.environ.get("LIBTMUX_SOCKET") + if env_name: + return ("-L", env_name) + if no_caller_socket: + return () + return caller_server_args(caller, explicit=False) + def default_server(*, expose_operations: bool = False) -> FastMCP: """Build a synchronous FastMCP server over a :class:`~..engines.SubprocessEngine`. @@ -79,8 +113,11 @@ def default_server(*, expose_operations: bool = False) -> FastMCP: """ from libtmux.experimental.engines import SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server + from libtmux.experimental.mcp.vocabulary._caller import CallerContext - return build_server(SubprocessEngine(), expose_operations=expose_operations) + ctx = CallerContext.discover() + engine = SubprocessEngine(server_args=_socket_args(ctx)) + return build_server(engine, expose_operations=expose_operations, caller=ctx) def default_async_server( @@ -100,9 +137,13 @@ def default_async_server( from libtmux.experimental.engines import AsyncControlModeEngine from libtmux.experimental.mcp.events import EventMode, EventSource from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + ctx = CallerContext.discover() + engine = AsyncControlModeEngine(server_args=_socket_args(ctx)) return build_async_server( - AsyncControlModeEngine(), + engine, + caller=ctx, expose_operations=expose_operations, events=t.cast("EventMode", events), event_source=t.cast("EventSource", event_source), @@ -149,23 +190,53 @@ def main(argv: Sequence[str] | None = None) -> None: default=os.environ.get("LIBTMUX_MCP_EVENT_SOURCE", "subscription"), help="event substrate (async server only)", ) + parser.add_argument( + "--socket-path", + help="tmux -S socket path (overrides caller-socket discovery)", + ) + parser.add_argument( + "--socket-name", + help="tmux -L socket name (overrides caller-socket discovery)", + ) + parser.add_argument( + "--no-caller-socket", + action="store_true", + help="do not auto-bind to the discovered caller's tmux socket", + ) args = parser.parse_args(argv) try: + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + ctx = CallerContext.discover() + srv_args = _socket_args( + ctx, + socket_path=args.socket_path, + socket_name=args.socket_name, + no_caller_socket=args.no_caller_socket, + ) if args.sync: from libtmux.experimental.engines import SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server server = build_server( - SubprocessEngine(), + SubprocessEngine(server_args=srv_args), name=args.name, expose_operations=args.operations, + caller=ctx, ) else: - server = default_async_server( + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.events import EventMode, EventSource + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + AsyncControlModeEngine(server_args=srv_args), + name=args.name, expose_operations=args.operations, - events=args.events, - event_source=args.event_source, + events=t.cast("EventMode", args.events), + event_source=t.cast("EventSource", args.event_source), + caller=ctx, ) except ImportError: sys.stderr.write( diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 363b5c380..7d7cd7079 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -509,6 +509,16 @@ def build_workspace( mcp.add_tool(tool) +def _stash_caller(engine: t.Any, ctx: CallerContext) -> None: + """Stash the discovered caller on the engine so the tool bodies can read it. + + The curated tool bodies bind only ``engine``; stashing the once-discovered + context here (read by :func:`~.vocabulary._resolve.caller_of`) threads caller + identity to them without changing every tool signature. + """ + engine._caller_context = ctx + + def build_server( engine: TmuxEngine, *, @@ -517,16 +527,19 @@ def build_server( include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, + caller: CallerContext | None = None, ) -> FastMCP: """Build a synchronous FastMCP server over a sync *engine*. The sync wrapper: the curated tools are the derived sync twins, which FastMCP offloads to a worker thread. Prefer :func:`build_async_server` for the - async-first surface and the event stream. + async-first surface and the event stream. *caller* defaults to + :meth:`CallerContext.discover`. """ from fastmcp import FastMCP - ctx = CallerContext.from_env() + ctx = caller if caller is not None else CallerContext.discover() + _stash_caller(engine, ctx) mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=False) @@ -554,6 +567,7 @@ def build_async_server( include_plan_tools: bool = True, events: EventMode = "push", event_source: EventSource = "subscription", + caller: CallerContext | None = None, ) -> FastMCP: """Build the async-first FastMCP server over an async *engine*. @@ -561,12 +575,14 @@ def build_async_server( awaited directly on FastMCP's event loop. When *engine* supports a notification stream (a control-mode engine), the live event tools are registered per *events* (``"push"``/``"pull"``/``"both"``/``"off"``). + *caller* defaults to :meth:`CallerContext.discover`. """ from fastmcp import FastMCP from libtmux.experimental.mcp.events import register_events - ctx = CallerContext.from_env() + ctx = caller if caller is not None else CallerContext.discover() + _stash_caller(engine, ctx) mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) diff --git a/src/libtmux/experimental/mcp/vocabulary/_bridge.py b/src/libtmux/experimental/mcp/vocabulary/_bridge.py index a473bbe97..db78380d7 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_bridge.py +++ b/src/libtmux/experimental/mcp/vocabulary/_bridge.py @@ -50,6 +50,16 @@ class SyncToAsyncEngine: def __init__(self, engine: TmuxEngine) -> None: self._engine = engine + def __getattr__(self, name: str) -> t.Any: + """Delegate unknown attributes to the wrapped engine. + + Keeps the wrapper transparent for the attributes the vocabulary reads off + an engine -- ``server_args`` (socket scoping) and the stashed + ``_caller_context`` -- so the sync surface sees the same identity the + async surface does. + """ + return getattr(self._engine, name) + async def run(self, request: CommandRequest) -> CommandResult: """Run one command on the wrapped sync engine (resolves inline).""" return self._engine.run(request) diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py index 7cd6c75c9..30942c2af 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_caller.py +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -1,28 +1,39 @@ -"""Caller context: who launched this MCP server, read from its own environment. +"""Caller context: who launched this MCP server, discovered from the environment. A tmux ``-C`` control client resolves a no-target or relative target against its *own* cursor pane, never the pane that launched the controlling process -- so the -caller pane is knowable only from the server process's environment. A process -spawned inside a tmux pane inherits ``TMUX_PANE`` (its ``%N`` id) and ``TMUX`` -(``socket-path,server-pid,session-id``); those are fixed for the process -lifetime, so the curated tools can read them at call time and the adapter can -read them once for the server instructions -- both see the same launching pane. - -Everything here is pure (no tmux call, no fastmcp): the whole point is to *avoid* -asking tmux, which would answer for the control client instead of the caller. A -pane id is unique only within one tmux server, so :func:`is_strict_caller` -socket-scopes the comparison rather than trusting a bare ``%N``. +caller pane is knowable only from the server process's environment, not by asking +tmux. A process spawned inside a tmux pane inherits ``TMUX_PANE`` (its ``%N``) and +``TMUX`` (``socket-path,server-pid,session-id``). + +But real launchers strip that env: an agent harness may hold ``TMUX``/ +``TMUX_PANE`` while the ``uv run`` child that became this server does not. So +:meth:`CallerContext.discover` layers the server's own env, an explicit override, +and a bounded same-uid ``/proc`` parent walk (:mod:`._proc`) to recover the pane. + +Everything here is pure (no tmux call, no fastmcp). A pane id is unique only +within one tmux server, so identity is socket-scoped: :func:`is_strict_caller` +(realpath-only, for the ``is_caller`` annotation) and the fail-safe +:func:`is_conservative_caller` (true-when-uncertain, for destructive guards). """ from __future__ import annotations +import dataclasses import os import os.path +import pathlib import typing as t from dataclasses import dataclass +from libtmux.experimental.mcp.vocabulary._proc import ( + read_proc_environ, + read_proc_ppid, + read_proc_uid, +) + if t.TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Callable, Mapping, Sequence @dataclass(frozen=True) @@ -33,8 +44,8 @@ class CallerContext: -------- >>> env = {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} >>> c = CallerContext.from_env(env) - >>> (c.pane_id, c.socket_path, c.session_id, c.in_tmux) - ('%3', '/tmp/tmux-1000/default', '2', True) + >>> (c.pane_id, c.socket_path, c.session_id, c.in_tmux, c.source) + ('%3', '/tmp/tmux-1000/default', '2', True, 'process-env') >>> CallerContext.from_env({}).in_tmux False >>> CallerContext.from_env({"TMUX": "garbage"}).socket_path is None @@ -48,6 +59,7 @@ class CallerContext: server_pid: str | None = None session_id: str | None = None in_tmux: bool = False + source: str = "none" @classmethod def from_env(cls, environ: Mapping[str, str] | None = None) -> CallerContext: @@ -73,8 +85,114 @@ def from_env(cls, environ: Mapping[str, str] | None = None) -> CallerContext: server_pid=server_pid, session_id=session_id, in_tmux=pane is not None, + source="process-env" if pane is not None else "none", ) + @classmethod + def discover( + cls, + *, + environ: Mapping[str, str] | None = None, + read_env: Callable[[int], Mapping[str, str] | None] = read_proc_environ, + read_ppid: Callable[[int], int | None] = read_proc_ppid, + read_uid: Callable[[int], int | None] = read_proc_uid, + self_pid: int | None = None, + self_uid: int | None = None, + is_linux: bool | None = None, + max_depth: int = 32, + ) -> CallerContext: + """Discover the caller pane: own env, then an override, then a /proc walk. + + Recovers the launching pane even when the MCP's own environment was + stripped. Precedence (first source that is inside tmux wins, recorded in + :attr:`source`): + + 1. ``process-env`` -- the server's own ``TMUX``/``TMUX_PANE``. + 2. ``explicit-override`` -- ``LIBTMUX_MCP_CALLER_PANE`` (+ optional + ``LIBTMUX_MCP_CALLER_TMUX``); the trusted escape hatch. + 3. ``parent-walk`` -- a bounded, same-uid Linux ``/proc`` ancestor climb + (disabled by ``LIBTMUX_MCP_DISCOVER=0`` or off Linux). + + Never raises: a reader failure, uid mismatch, or missing ``/proc`` + degrades to the next source and ultimately ``source="none"``. The reader + callables are injectable so the walk is unit-testable without ``/proc``. + + Examples + -------- + >>> env = {10: {}, 20: {}, 30: {"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}} + >>> ppid = {10: 20, 20: 30, 30: 1} + >>> c = CallerContext.discover( + ... environ={}, + ... read_env=env.get, + ... read_ppid=ppid.get, + ... read_uid=lambda _pid: 1000, + ... self_pid=10, + ... self_uid=1000, + ... is_linux=True, + ... ) + >>> (c.pane_id, c.socket_path, c.source) + ('%3', '/tmp/s', 'parent-walk') + """ + env = os.environ if environ is None else environ + own = cls.from_env(env) + if own.in_tmux: + return own + override_pane = env.get("LIBTMUX_MCP_CALLER_PANE") + if override_pane: + override: dict[str, str] = {"TMUX_PANE": override_pane} + override_tmux = env.get("LIBTMUX_MCP_CALLER_TMUX") + if override_tmux: + override["TMUX"] = override_tmux + return dataclasses.replace( + cls.from_env(override), + source="explicit-override", + ) + if env.get("LIBTMUX_MCP_DISCOVER") == "0": + return cls(source="none") + linux = pathlib.Path("/proc").is_dir() if is_linux is None else is_linux + if linux: + walked = cls._parent_walk( + read_env, + read_ppid, + read_uid, + self_pid, + self_uid, + max_depth, + ) + if walked is not None: + return walked + return cls(source="none") + + @classmethod + def _parent_walk( + cls, + read_env: Callable[[int], Mapping[str, str] | None], + read_ppid: Callable[[int], int | None], + read_uid: Callable[[int], int | None], + self_pid: int | None, + self_uid: int | None, + max_depth: int, + ) -> CallerContext | None: + """Climb the parent chain for the first same-uid ancestor inside tmux.""" + pid = os.getpid() if self_pid is None else self_pid + uid = os.getuid() if self_uid is None else self_uid + seen: set[int] = set() + for _ in range(max_depth): + ppid = read_ppid(pid) + if ppid is None or ppid in (0, 1) or ppid in seen: + return None + if read_uid(ppid) != uid: + return None # never read a foreign or setuid parent's env + seen.add(ppid) + env = read_env(ppid) + if env is None: + return None + ctx = cls.from_env(env) + if ctx.in_tmux: + return dataclasses.replace(ctx, source="parent-walk") + pid = ppid + return None + def _scan_flag(args: Sequence[str], flag: str) -> str | None: """Read a tmux connection flag's value (joined ``-Sx`` or separated ``-S x``).""" @@ -110,6 +228,28 @@ def engine_socket(engine: t.Any) -> str | None: return _scan_flag(args, "-L") +def caller_server_args(caller: CallerContext, *, explicit: bool) -> tuple[str, ...]: + """Return ``("-S", socket)`` to bind the caller's server, or ``()``. + + Binds only when the caller's socket was discovered and no explicit socket + override was supplied -- so a stripped-env MCP still drives the user's own + tmux server instead of spawning a fresh default one. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> caller_server_args(caller, explicit=False) + ('-S', '/tmp/s') + >>> caller_server_args(caller, explicit=True) + () + >>> caller_server_args(CallerContext.from_env({}), explicit=False) + () + """ + if explicit or not caller.in_tmux or caller.socket_path is None: + return () + return ("-S", caller.socket_path) + + def socket_matches(socket: str | None, caller: CallerContext) -> bool: """Whether an engine *socket* selector denotes the caller's tmux server. @@ -140,6 +280,48 @@ def socket_matches(socket: str | None, caller: CallerContext) -> bool: return os.path.realpath(expected) == os.path.realpath(caller.socket_path) +def socket_could_match(socket: str | None, caller: CallerContext) -> bool: + """Conservative socket comparator: True unless the caller is provably elsewhere. + + The fail-safe counterpart to :func:`socket_matches`, for destructive guards: + it blocks (returns ``True``) whenever it cannot *disprove* that *socket* is + the caller's server -- an unknown caller socket, an ambient default engine, + or a last-chance basename match all count, so a self-kill is refused under + uncertainty (e.g. a ``$TMUX_TMPDIR`` divergence). + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> socket_could_match(None, caller) + True + >>> socket_could_match("/tmp/s", caller) + True + >>> socket_could_match("/tmp/other", caller) + False + >>> socket_could_match(None, CallerContext.from_env({})) + False + """ + if not caller.in_tmux: + return False + if caller.socket_path is None: + return True + if socket is None: + return True + if "/" in socket: + try: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + except OSError: + return socket == caller.socket_path + tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" + expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" + try: + if os.path.realpath(expected) == os.path.realpath(caller.socket_path): + return True + except OSError: + pass + return caller.socket_path.rsplit("/", 1)[-1] == socket + + def is_strict_caller( pane_id: str | None, socket: str | None, @@ -149,7 +331,8 @@ def is_strict_caller( Strict: requires pane-id equality *and* a confirmed socket match, since a pane id is unique only within one tmux server. Bare pane-id equality is - rejected to avoid a cross-server false positive. + rejected to avoid a cross-server false positive. Used for the ``is_caller`` + annotation and the caller-default origin -- both must demand a positive match. Examples -------- @@ -166,3 +349,30 @@ def is_strict_caller( if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: return False return socket_matches(socket, caller) + + +def is_conservative_caller( + pane_id: str | None, + socket: str | None, + caller: CallerContext, +) -> bool: + """Whether *pane_id* could be the caller's pane (conservative, for guards). + + Scoped to a matching pane id but biased to block under socket uncertainty -- + the comparator the self-kill guards use, so better discovery never makes the + destructive surface fail open. A different pane, or the same ``%N`` on a + provably different socket, is not the caller. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}) + >>> is_conservative_caller("%3", None, caller) + True + >>> is_conservative_caller("%9", None, caller) + False + >>> is_conservative_caller("%3", "/tmp/other", caller) + False + """ + if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: + return False + return socket_could_match(socket, caller) diff --git a/src/libtmux/experimental/mcp/vocabulary/_proc.py b/src/libtmux/experimental/mcp/vocabulary/_proc.py new file mode 100644 index 000000000..a8fc35c5c --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_proc.py @@ -0,0 +1,87 @@ +"""Linux ``/proc`` readers for the caller-discovery parent walk (pure, fail-closed). + +Recovering the launching pane when the MCP's own environment is stripped means +walking the process tree: a launcher (e.g. an agent harness) may hold ``TMUX`` / +``TMUX_PANE`` while the ``uv run`` child that became this server does not. Each +reader returns ``None`` on any failure (missing ``/proc``, permission, a dead +pid) so discovery degrades to "not in tmux" rather than raising -- matching the +lenient list-accessor contract. Only ``TMUX`` / ``TMUX_PANE`` are ever read from +another process's environment (env-minimisation -- never materialise secrets). +""" + +from __future__ import annotations + +import pathlib +import typing as t + +if t.TYPE_CHECKING: + from collections.abc import Mapping + +#: The only environment keys ever read from another process. +_WANTED = ("TMUX", "TMUX_PANE") + + +def read_proc_environ(pid: int) -> Mapping[str, str] | None: + """Return a process's ``TMUX``/``TMUX_PANE`` env only, or ``None`` on failure. + + ``/proc//environ`` is ``KEY=VAL`` pairs joined by NUL bytes. Any read + error (missing, permission, dead pid -- all ``OSError`` subclasses) yields + ``None``. + """ + try: + raw = pathlib.Path(f"/proc/{pid}/environ").read_bytes() + except OSError: + return None + out: dict[str, str] = {} + for item in raw.split(b"\x00"): + if b"=" not in item: + continue + key, value = item.split(b"=", 1) + name = key.decode(errors="replace") + if name in _WANTED: + out[name] = value.decode(errors="replace") + return out + + +def _ppid_from_stat(data: bytes) -> int | None: + """Parse the parent pid out of ``/proc//stat`` bytes. + + The ``comm`` field (2nd) is paren-wrapped and may itself contain spaces or + parens, so the parse anchors on the *last* ``)``; the fields after it are + space-separated, with state at index 0 and ppid at index 1. + + Examples + -------- + >>> _ppid_from_stat(b"1234 (we ird (name)) S 99 1234 1234 0 -1") + 99 + >>> _ppid_from_stat(b"garbage") is None + True + """ + try: + return int(data[data.rindex(b")") + 1 :].split()[1]) + except (ValueError, IndexError): + return None + + +def read_proc_ppid(pid: int) -> int | None: + """Return a process's parent pid from ``/proc//stat``, or ``None``.""" + try: + data = pathlib.Path(f"/proc/{pid}/stat").read_bytes() + except OSError: + return None + return _ppid_from_stat(data) + + +def read_proc_uid(pid: int) -> int | None: + """Return a process's real uid from ``/proc//status``, or ``None``. + + The ``Uid:`` line is ``real effective saved-set filesystem``; the first + field (the real uid) is what :func:`os.getuid` returns. + """ + try: + for line in pathlib.Path(f"/proc/{pid}/status").read_bytes().splitlines(): + if line.startswith(b"Uid:"): + return int(line.split()[1]) + except (OSError, ValueError, IndexError): + return None + return None diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py index 87872daba..b61336eeb 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_resolve.py +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -15,6 +15,7 @@ from libtmux.experimental.mcp.vocabulary._caller import ( CallerContext, engine_socket, + socket_could_match, socket_matches, ) from libtmux.experimental.ops import DisplayMessage, ListPanes, SelectPane, arun @@ -145,16 +146,17 @@ async def resolve_origin( """Resolve a caller-relative origin to a concrete pane id. An explicit *origin* is resolved as a target; ``origin=None`` means the - caller's own pane (from the server's environment), socket-scoped exactly - like :func:`~._caller.is_strict_caller` because a ``%N`` is unique only - within one server. When there is no trustworthy caller pane -- the server is - not inside tmux, or its pane belongs to a different server than this engine - targets -- this raises rather than guessing the active pane, so the caller - must pass an explicit origin. + caller's own pane (from the discovered caller context -- own env, an explicit + override, or a ``/proc`` parent walk), socket-scoped exactly like + :func:`~._caller.is_strict_caller` because a ``%N`` is unique only within one + server. When there is no trustworthy caller pane -- the server is not inside + tmux, or its pane belongs to a different server than this engine targets -- + this raises rather than guessing the active pane, so the caller must pass an + explicit origin. """ if origin is not None: return await pane_id(engine, origin, version) - caller = CallerContext.from_env() + caller = caller_of(engine) if caller.pane_id and socket_matches(engine_socket(engine), caller): return caller.pane_id raise_target_hint( @@ -196,3 +198,73 @@ def reject_relative_special(resolved: Target | None) -> None: "composed capture_relative_pane / grep_relative_pane (origin defaults to " "your pane)", ) + + +def caller_of(engine: AsyncTmuxEngine) -> CallerContext: + """Return the caller context discovered at build time (stashed on the engine). + + Falls back to a fresh :meth:`~._caller.CallerContext.from_env` read when no + context was stashed (the engine was built outside the adapter). + """ + stashed = getattr(engine, "_caller_context", None) + if isinstance(stashed, CallerContext): + return stashed + return CallerContext.from_env() + + +async def session_id_of( + engine: AsyncTmuxEngine, + target: str | Target, + version: str | None, +) -> str: + """Return the session id (``$N``) containing *target* (a pane/window/session).""" + result = await arun( + DisplayMessage(target=resolve_target(target), message="#{session_id}"), + engine, + version=version, + ) + result.raise_for_status() + return result.text.strip() + + +async def guard_self_kill( + engine: AsyncTmuxEngine, + *, + pane: str | None = None, + window: str | None = None, + session: str | None = None, + version: str | None = None, +) -> None: + """Refuse a destructive op aimed at the caller's own pane/window/session. + + Socket-scoped first (``%N``/``@N``/``$N`` are per-server counters that + collide across servers), then the caller's pane is mapped to its window / + session *via the engine* (``$TMUX`` carries no window id). Uses the + conservative comparator so a self-kill fails safe under socket uncertainty. + Raises a refusal hint; a different pane/window/session, or a cross-socket + target with the same id, is not refused. + """ + caller = caller_of(engine) + if not caller.in_tmux or caller.pane_id is None: + return + if not socket_could_match(engine_socket(engine), caller): + return + if pane is not None and caller.pane_id == pane: + raise_target_hint( + f"refusing to kill pane {pane}: it runs this MCP server. Target a " + "different pane, or run the tmux command manually if intended.", + ) + if window is not None: + caller_window = await window_id(engine, PaneId(caller.pane_id), version) + if caller_window == window: + raise_target_hint( + f"refusing to kill window {window}: it holds this MCP server's " + "pane. Use a manual tmux command if intended.", + ) + if session is not None: + caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) + if caller_session == session: + raise_target_hint( + f"refusing to kill session {session}: it holds this MCP server's " + "pane. Use a manual tmux command if intended.", + ) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 69bca9321..51da8afcb 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -22,9 +22,9 @@ from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced from libtmux.experimental.mcp.vocabulary._caller import ( - CallerContext, engine_socket, is_strict_caller, + socket_could_match, ) from libtmux.experimental.mcp.vocabulary._geometry import ( Corner, @@ -36,7 +36,10 @@ from libtmux.experimental.mcp.vocabulary._resolve import ( DIR_FLAG, active_pane_id, + caller_of, + guard_self_kill, opt_target, + pane_id, raise_target_hint, reject_relative_special, resolve_origin, @@ -246,7 +249,7 @@ async def asearch_panes( matcher = _compile(pattern, ignore_case=ignore_case) listing = await arun(ListPanes(all_panes=True), engine, version=version) listing.raise_for_status() - caller = CallerContext.from_env() + caller = caller_of(engine) socket = engine_socket(engine) matches: list[PaneMatch] = [] for row in listing.rows[:max_panes]: @@ -373,6 +376,9 @@ async def arespawn_pane( """Restart a pane's process in place (``respawn-pane``).""" resolved = resolve_target(target) reject_relative_special(resolved) + await guard_self_kill( + engine, pane=await pane_id(engine, target, version), version=version + ) ( await arun( RespawnPane( @@ -397,6 +403,11 @@ async def akill_pane( """Kill a pane (or all others in its window with ``others=True``).""" resolved = resolve_target(target) reject_relative_special(resolved) + target_pane = await pane_id(engine, target, version) + if others: + await _guard_kill_others(engine, target_pane, version) + else: + await guard_self_kill(engine, pane=target_pane, version=version) ( await arun( KillPane(target=resolved, others=others), @@ -406,6 +417,30 @@ async def akill_pane( ).raise_for_status() +async def _guard_kill_others( + engine: AsyncTmuxEngine, + target_pane: str, + version: str | None, +) -> None: + """Refuse ``kill_pane(others=True)`` when the caller is a sibling of the target. + + ``others=True`` keeps the target and kills every other pane in its window, so + the danger is the caller pane being one of those siblings (not the target). + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id or caller.pane_id == target_pane: + return + if not socket_could_match(engine_socket(engine), caller): + return + target_window = await window_id(engine, PaneId(target_pane), version) + caller_window = await window_id(engine, PaneId(caller.pane_id), version) + if caller_window == target_window: + raise_target_hint( + f"refusing to kill the other panes of window {target_window}: one of " + "them runs this MCP server. Use a manual tmux command if intended.", + ) + + async def alist_panes( engine: AsyncTmuxEngine, target: str | Target | None = None, @@ -421,7 +456,7 @@ async def alist_panes( """ result = await arun(ListPanes(all_panes=all_panes), engine, version=version) result.raise_for_status() - caller = CallerContext.from_env() + caller = caller_of(engine) socket = engine_socket(engine) rows = tuple( { @@ -482,7 +517,9 @@ async def aresolve_relative_pane( Resolved from layout geometry (the ``pane_left/top/right/bottom`` the list template already carries) -- robust across tmux versions, and without moving the active pane. ``origin=None`` means the caller's own pane (the pane that - launched this MCP), falling back to the active pane outside tmux. + launched this MCP), resolved only when this engine targets the caller's tmux + server; otherwise an explicit ``origin`` is required. This never falls back to + tmux's active pane (the control client's cursor, not the caller). """ origin_id = await resolve_origin(engine, origin, version) if not origin_id: diff --git a/src/libtmux/experimental/mcp/vocabulary/session.py b/src/libtmux/experimental/mcp/vocabulary/session.py index 419233265..e5e0e65e1 100644 --- a/src/libtmux/experimental/mcp/vocabulary/session.py +++ b/src/libtmux/experimental/mcp/vocabulary/session.py @@ -12,6 +12,7 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._resolve import guard_self_kill, session_id_of from libtmux.experimental.mcp.vocabulary._results import Listing, SessionResult from libtmux.experimental.ops import ( HasSession, @@ -88,6 +89,8 @@ async def akill_session( version: str | None = None, ) -> None: """Kill a session (mirrors ``session.kill``).""" + target_session = await session_id_of(engine, target, version) + await guard_self_kill(engine, session=target_session, version=version) ( await arun(KillSession(target=resolve_target(target)), engine, version=version) ).raise_for_status() diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index c9d856b53..5a69982ee 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -5,6 +5,17 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._caller import ( + engine_socket, + socket_could_match, +) +from libtmux.experimental.mcp.vocabulary._resolve import ( + caller_of, + guard_self_kill, + raise_target_hint, + session_id_of, + window_id, +) from libtmux.experimental.mcp.vocabulary._results import Listing, WindowResult from libtmux.experimental.ops import ( KillWindow, @@ -17,7 +28,7 @@ SwapWindow, arun, ) -from libtmux.experimental.ops._types import Target +from libtmux.experimental.ops._types import PaneId, Target async def acreate_window( @@ -126,15 +137,49 @@ async def akill_window( version: str | None = None, ) -> None: """Kill a window (or all others in its session with ``others=True``).""" + resolved = resolve_target(target) + target_window = await window_id(engine, resolved, version) + if others: + await _guard_kill_other_windows(engine, target, target_window, version) + else: + await guard_self_kill(engine, window=target_window, version=version) ( await arun( - KillWindow(target=resolve_target(target), others=others), + KillWindow(target=resolved, others=others), engine, version=version, ) ).raise_for_status() +async def _guard_kill_other_windows( + engine: AsyncTmuxEngine, + target: str | Target, + target_window: str, + version: str | None, +) -> None: + """Refuse ``kill_window(others=True)`` when the caller is a same-session sibling. + + ``others=True`` keeps the target window and kills every other window in its + session, so the danger is the caller's window being one of those siblings. + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id: + return + if not socket_could_match(engine_socket(engine), caller): + return + caller_window = await window_id(engine, PaneId(caller.pane_id), version) + if caller_window == target_window: + return # the kept target window + target_session = await session_id_of(engine, target, version) + caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) + if caller_session == target_session: + raise_target_hint( + f"refusing to kill the other windows of session {caller_session}: one " + "holds this MCP server's pane. Use a manual tmux command if intended.", + ) + + async def alist_windows( engine: AsyncTmuxEngine, target: str | Target | None = None, diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index ce208774c..cc67aa18a 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -14,10 +14,18 @@ import pytest from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine -from libtmux.experimental.mcp.vocabulary import create_session, kill_session, list_panes +from libtmux.experimental.mcp.vocabulary import ( + create_session, + kill_pane, + kill_session, + kill_window, + list_panes, + respawn_pane, +) from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine from libtmux.experimental.mcp.vocabulary._caller import ( CallerContext, + caller_server_args, engine_socket, is_strict_caller, ) @@ -129,6 +137,8 @@ def test_instructions_outside_tmux(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TMUX", raising=False) monkeypatch.delenv("TMUX_PANE", raising=False) + # Disable the /proc parent walk so a test runner inside tmux is not discovered. + monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") assert "not running inside a tmux pane" in (server.instructions or "") @@ -143,9 +153,11 @@ def test_is_caller_row_flag_live( try: pane = created.first_pane_id assert pane is not None - socket = engine_socket(engine) or "" + real_socket = session.server.cmd( + "display-message", "-p", "#{socket_path}" + ).stdout[0] monkeypatch.setenv("TMUX_PANE", pane) - monkeypatch.setenv("TMUX", f"{socket},0,0") + monkeypatch.setenv("TMUX", f"{real_socket},0,0") flagged = [ row["pane_id"] for row in list_panes(engine).rows @@ -153,6 +165,10 @@ def test_is_caller_row_flag_live( ] assert pane in flagged finally: + # Clear the simulated caller env so the self-kill guard does not refuse + # to tear down the session we pointed it at. + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) kill_session(engine, created.session_id) @@ -191,3 +207,144 @@ async def main() -> str: # Cross-server: the env %3 is refused; the caller must name an origin. with pytest.raises(ToolError, match="explicit origin"): asyncio.run(main()) + + +# --------------------------------------------------------------------------- # +# CallerContext.discover -- precedence + injectable /proc parent walk +# --------------------------------------------------------------------------- # +def test_discover_process_env_wins() -> None: + """The server's own env beats every other source.""" + ctx = CallerContext.discover( + environ={"TMUX_PANE": "%1", "TMUX": "/s,1,2"}, is_linux=True + ) + assert ctx.source == "process-env" + assert ctx.pane_id == "%1" + + +def test_discover_override_beats_walk() -> None: + """LIBTMUX_MCP_CALLER_PANE is the trusted override (no /proc walk).""" + ctx = CallerContext.discover( + environ={"LIBTMUX_MCP_CALLER_PANE": "%5", "LIBTMUX_MCP_CALLER_TMUX": "/s,1,2"}, + is_linux=True, + ) + assert ctx.source == "explicit-override" + assert (ctx.pane_id, ctx.socket_path) == ("%5", "/s") + + +def test_discover_parent_walk_recovers_stripped_env() -> None: + """A stripped child recovers TMUX from a same-uid ancestor.""" + fake_env = {10: {}, 20: {"TMUX_PANE": "%9", "TMUX": "/tmp/sock,7,3"}} + fake_ppid = {10: 20, 20: 1} + ctx = CallerContext.discover( + environ={}, + read_env=fake_env.get, + read_ppid=fake_ppid.get, + read_uid=lambda _pid: 1000, + self_pid=10, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "parent-walk" + assert (ctx.pane_id, ctx.socket_path) == ("%9", "/tmp/sock") + + +def test_discover_refuses_foreign_uid() -> None: + """The walk stops at a differently-owned ancestor (no env read).""" + fake_env = {10: {}, 20: {"TMUX_PANE": "%9", "TMUX": "/s,1,2"}} + fake_ppid = {10: 20, 20: 1} + ctx = CallerContext.discover( + environ={}, + read_env=fake_env.get, + read_ppid=fake_ppid.get, + read_uid=lambda _pid: 99999, + self_pid=10, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "none" + + +def test_discover_off_linux() -> None: + """No /proc means no walk (fail closed to source='none').""" + assert CallerContext.discover(environ={}, is_linux=False).source == "none" + + +def test_discover_disabled_by_env() -> None: + """LIBTMUX_MCP_DISCOVER=0 disables the parent walk.""" + ctx = CallerContext.discover(environ={"LIBTMUX_MCP_DISCOVER": "0"}, is_linux=True) + assert ctx.source == "none" + + +def test_discover_fails_closed_on_reader_failure() -> None: + """A reader returning None mid-walk degrades to source='none'.""" + ctx = CallerContext.discover( + environ={}, + read_env=lambda _pid: None, + read_ppid=lambda _pid: 2, + read_uid=lambda _pid: 1000, + self_pid=1, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "none" + + +def test_caller_server_args_binds_caller_socket() -> None: + """The binding decision yields -S only for a discovered, non-overridden socket.""" + ctx = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/sock,1,2"}) + assert caller_server_args(ctx, explicit=False) == ("-S", "/sock") + assert caller_server_args(ctx, explicit=True) == () + assert caller_server_args(CallerContext.from_env({}), explicit=False) == () + + +# --------------------------------------------------------------------------- # +# Self-kill guards +# --------------------------------------------------------------------------- # +def test_kill_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """kill_pane refuses the pane running this MCP server.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + with pytest.raises(ToolError, match="this MCP server"): + kill_pane(ConcreteEngine(), "%9") + + +def test_respawn_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """respawn_pane (which destroys the process) refuses the caller's pane.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + with pytest.raises(ToolError, match="this MCP server"): + respawn_pane(ConcreteEngine(), "%9") + + +def test_kill_pane_allows_other_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """A different pane is not the caller, so it is not refused.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + assert kill_pane(ConcreteEngine(), "%1") is None + + +def test_self_kill_refusals_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Killing the caller's own pane/window/session is refused on a real server.""" + engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(engine, name="selfkill") + try: + pane = created.first_pane_id + assert pane is not None + monkeypatch.setenv("TMUX_PANE", pane) + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + with pytest.raises(ToolError, match="this MCP server"): + kill_pane(engine, pane) + with pytest.raises(ToolError, match="this MCP server"): + kill_window(engine, created.first_window_id or "") + with pytest.raises(ToolError, match="this MCP server"): + kill_session(engine, created.session_id) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(engine, created.session_id) diff --git a/tests/experimental/mcp/test_proc.py b/tests/experimental/mcp/test_proc.py new file mode 100644 index 000000000..8a0d73e71 --- /dev/null +++ b/tests/experimental/mcp/test_proc.py @@ -0,0 +1,43 @@ +"""The Linux ``/proc`` readers for caller discovery: byte parsing + fail-closed.""" + +from __future__ import annotations + +import os + +from libtmux.experimental.mcp.vocabulary._proc import ( + _ppid_from_stat, + read_proc_environ, + read_proc_ppid, + read_proc_uid, +) + + +def test_ppid_from_stat_survives_parens_in_comm() -> None: + """The ppid parse anchors on the last ')', so a paren-laden comm is fine.""" + assert _ppid_from_stat(b"1234 (tmux: serv (x)) S 99 1234 1234 0 -1") == 99 + + +def test_ppid_from_stat_garbage_is_none() -> None: + """Unparseable stat bytes yield None, never an exception.""" + assert _ppid_from_stat(b"nonsense") is None + + +def test_real_readers_match_os() -> None: + """The real /proc readers agree with os.getppid()/os.getuid() for self.""" + assert read_proc_ppid(os.getpid()) == os.getppid() + assert read_proc_uid(os.getpid()) == os.getuid() + + +def test_environ_reader_minimises_keys() -> None: + """The environ reader exposes only TMUX/TMUX_PANE (never other secrets).""" + env = read_proc_environ(os.getpid()) + assert env is not None + assert set(env) <= {"TMUX", "TMUX_PANE"} + + +def test_readers_fail_closed_on_bad_pid() -> None: + """An unreadable pid yields None from every reader (never raises).""" + bad = -1 + assert read_proc_environ(bad) is None + assert read_proc_ppid(bad) is None + assert read_proc_uid(bad) is None From c787d018d4b81a15b255236d98a38225180a0883 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:47:25 -0500 Subject: [PATCH 069/223] Mcp(fix): Harden self-kill guards + socket scoping why: An adversarial review of the caller-discovery work surfaced fail-unsafe and over-broad edges in the new guards. what: - guard_self_kill and the others=True sibling guards now resolve the caller's own pane to its window/session through a fail-safe helper: a caller pane absent from the engine's server is not a self-kill, so a kill on an unrelated target no longer raises a raw tmux error. - Scope the ambient (socket=None) branch of both comparators to a process-env caller, so a parent-walked caller is not matched to an unbound default engine (which would mis-target resolve_origin reads and over-refuse kills under --no-caller-socket). - Wrap socket_matches' realpath comparisons (a $TMUX-controlled path can raise) so the read-only tools degrade instead of crashing. - Guard the op_* per-operation kill/respawn surface too, closing the bypass when --operations is enabled. - Name the caller pane in the others=True refusal hints; correct the get_caller_context docstring; document the socket/caller precedence in --help; make the adapter tests hermetic (no host /proc walk). --- src/libtmux/experimental/mcp/__init__.py | 8 +++ .../experimental/mcp/fastmcp_adapter.py | 14 +++- .../experimental/mcp/vocabulary/_caller.py | 23 ++++-- .../experimental/mcp/vocabulary/_resolve.py | 71 ++++++++++++++++--- .../experimental/mcp/vocabulary/pane.py | 10 ++- .../experimental/mcp/vocabulary/window.py | 24 ++++--- tests/experimental/mcp/conftest.py | 18 +++++ tests/experimental/mcp/test_caller.py | 34 +++++++++ 8 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 tests/experimental/mcp/conftest.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 0b3f89033..1c1d96ade 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -166,6 +166,14 @@ def main(argv: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser( prog="libtmux-engine-mcp", description="Run the experimental libtmux typed-ops MCP server (stdio).", + epilog=( + "socket precedence: --socket-path > --socket-name > " + "$LIBTMUX_SOCKET_PATH > $LIBTMUX_SOCKET > discovered caller socket > " + "default; --no-caller-socket drops the caller socket. " + "caller identity: $TMUX/$TMUX_PANE > $LIBTMUX_MCP_CALLER_PANE " + "(+$LIBTMUX_MCP_CALLER_TMUX) > /proc parent walk " + "($LIBTMUX_MCP_DISCOVER=0 disables)." + ), ) parser.add_argument("--name", default="tmux", help="server name") parser.add_argument( diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 7d7cd7079..40ce25acb 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -246,7 +246,11 @@ def register_caller_context(mcp: FastMCP, ctx: CallerContext) -> None: from mcp.types import ToolAnnotations def get_caller_context() -> CallerContext: - """Return the tmux pane/server that launched this MCP (from its env).""" + """Return the tmux pane/server discovered for this MCP. + + From the server's own env, an explicit override, or a bounded ``/proc`` + parent walk -- inspect the ``source`` field for which. + """ return ctx tool = FunctionTool.from_function( @@ -314,6 +318,8 @@ def register_operations( from mcp.types import ToolAnnotations from pydantic import PrivateAttr + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + from libtmux.experimental.mcp.vocabulary._resolve import guard_destructive_op from libtmux.experimental.ops import arun as arun_op, run as run_op from libtmux.experimental.ops.serialize import result_to_dict @@ -326,6 +332,12 @@ class _OperationTool(Tool): async def run(self, arguments: dict[str, t.Any]) -> ToolResult: operation = self._descriptor.build(**arguments) + # The per-op surface dispatches around the curated guard, so apply the + # self-kill guard here too (a sync engine is wrapped to async for it). + guard_engine = ( + self._engine if self._is_async else SyncToAsyncEngine(self._engine) + ) + await guard_destructive_op(guard_engine, operation) if self._is_async: result = await arun_op(operation, self._engine) else: diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py index 30942c2af..ae080749f 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_caller.py +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -270,14 +270,27 @@ def socket_matches(socket: str | None, caller: CallerContext) -> bool: False """ if socket is None: - return caller.in_tmux and caller.socket_path is not None + # An unbound engine talks to the ambient $TMUX server, which is the + # caller's only when the MCP itself inherited it (process-env). A + # parent-walked caller's socket is NOT the ambient default. + return ( + caller.in_tmux + and caller.socket_path is not None + and caller.source == "process-env" + ) if caller.socket_path is None: return False if "/" in socket: - return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + try: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + except OSError: + return socket == caller.socket_path tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" - return os.path.realpath(expected) == os.path.realpath(caller.socket_path) + try: + return os.path.realpath(expected) == os.path.realpath(caller.socket_path) + except OSError: + return expected == caller.socket_path def socket_could_match(socket: str | None, caller: CallerContext) -> bool: @@ -306,7 +319,9 @@ def socket_could_match(socket: str | None, caller: CallerContext) -> bool: if caller.socket_path is None: return True if socket is None: - return True + # Ambient default engine: the caller's server only when the MCP inherited + # $TMUX (process-env), not when its pane was parent-walked or overridden. + return caller.source == "process-env" if "/" in socket: try: return os.path.realpath(socket) == os.path.realpath(caller.socket_path) diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py index b61336eeb..2de5c57e9 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_resolve.py +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -18,7 +18,13 @@ socket_could_match, socket_matches, ) -from libtmux.experimental.ops import DisplayMessage, ListPanes, SelectPane, arun +from libtmux.experimental.ops import ( + DisplayMessage, + ListPanes, + SelectPane, + TmuxCommandError, + arun, +) from libtmux.experimental.ops._types import PaneId, Special, Target #: Relative directional special tokens that resolve against the control client, @@ -227,6 +233,35 @@ async def session_id_of( return result.text.strip() +async def caller_window_or_none( + engine: AsyncTmuxEngine, + caller_pane: str, + version: str | None, +) -> str | None: + """Map the caller's pane to its window, or ``None`` if not on this server. + + Fails safe: a caller pane that does not exist on the engine's server (the + cross-server case the conservative gate tolerates) is *not* a self-kill, so + the lookup returns ``None`` rather than surfacing a raw tmux error. + """ + try: + return await window_id(engine, PaneId(caller_pane), version) + except TmuxCommandError: + return None + + +async def caller_session_or_none( + engine: AsyncTmuxEngine, + caller_pane: str, + version: str | None, +) -> str | None: + """Map the caller's pane to its session, or ``None`` if not on this server.""" + try: + return await session_id_of(engine, PaneId(caller_pane), version) + except TmuxCommandError: + return None + + async def guard_self_kill( engine: AsyncTmuxEngine, *, @@ -240,9 +275,10 @@ async def guard_self_kill( Socket-scoped first (``%N``/``@N``/``$N`` are per-server counters that collide across servers), then the caller's pane is mapped to its window / session *via the engine* (``$TMUX`` carries no window id). Uses the - conservative comparator so a self-kill fails safe under socket uncertainty. - Raises a refusal hint; a different pane/window/session, or a cross-socket - target with the same id, is not refused. + conservative comparator so a self-kill fails safe under socket uncertainty; + a caller pane absent from this engine's server fails safe to *allow* (it is + not a self-kill). Raises a refusal hint; a different pane/window/session, or a + cross-socket target with the same id, is not refused. """ caller = caller_of(engine) if not caller.in_tmux or caller.pane_id is None: @@ -255,16 +291,35 @@ async def guard_self_kill( "different pane, or run the tmux command manually if intended.", ) if window is not None: - caller_window = await window_id(engine, PaneId(caller.pane_id), version) - if caller_window == window: + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is not None and caller_window == window: raise_target_hint( f"refusing to kill window {window}: it holds this MCP server's " "pane. Use a manual tmux command if intended.", ) if session is not None: - caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) - if caller_session == session: + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is not None and caller_session == session: raise_target_hint( f"refusing to kill session {session}: it holds this MCP server's " "pane. Use a manual tmux command if intended.", ) + + +async def guard_destructive_op(engine: AsyncTmuxEngine, operation: t.Any) -> None: + """Apply the self-kill guard to a per-op kill/respawn operation, by kind. + + Closes the gap where the ``op_*`` per-operation surface dispatches around the + curated guard. The ``others=True`` sibling case is not fully covered here -- + prefer the curated kill tools when killing "all others". + """ + target = operation.target + if target is None: + return + kind = operation.kind + if kind in ("kill_pane", "respawn_pane"): + await guard_self_kill(engine, pane=await pane_id(engine, target, None)) + elif kind == "kill_window": + await guard_self_kill(engine, window=await window_id(engine, target, None)) + elif kind == "kill_session": + await guard_self_kill(engine, session=await session_id_of(engine, target, None)) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 51da8afcb..083922f2f 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -37,6 +37,7 @@ DIR_FLAG, active_pane_id, caller_of, + caller_window_or_none, guard_self_kill, opt_target, pane_id, @@ -432,12 +433,15 @@ async def _guard_kill_others( return if not socket_could_match(engine_socket(engine), caller): return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None: + return target_window = await window_id(engine, PaneId(target_pane), version) - caller_window = await window_id(engine, PaneId(caller.pane_id), version) if caller_window == target_window: raise_target_hint( - f"refusing to kill the other panes of window {target_window}: one of " - "them runs this MCP server. Use a manual tmux command if intended.", + f"refusing to kill the other panes of window {target_window}: pane " + f"{caller.pane_id} runs this MCP server. Kill panes individually " + f"(excluding {caller.pane_id}), or run the tmux command manually.", ) diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index 5a69982ee..978b7c421 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -11,6 +11,8 @@ ) from libtmux.experimental.mcp.vocabulary._resolve import ( caller_of, + caller_session_or_none, + caller_window_or_none, guard_self_kill, raise_target_hint, session_id_of, @@ -28,7 +30,7 @@ SwapWindow, arun, ) -from libtmux.experimental.ops._types import PaneId, Target +from libtmux.experimental.ops._types import Target async def acreate_window( @@ -168,16 +170,18 @@ async def _guard_kill_other_windows( return if not socket_could_match(engine_socket(engine), caller): return - caller_window = await window_id(engine, PaneId(caller.pane_id), version) - if caller_window == target_window: - return # the kept target window + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None or caller_window == target_window: + return # caller not on this server, or it is the kept target window target_session = await session_id_of(engine, target, version) - caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) - if caller_session == target_session: - raise_target_hint( - f"refusing to kill the other windows of session {caller_session}: one " - "holds this MCP server's pane. Use a manual tmux command if intended.", - ) + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is None or caller_session != target_session: + return + raise_target_hint( + f"refusing to kill the other windows of session {caller_session}: window " + f"{caller_window} holds this MCP server's pane {caller.pane_id}. Exclude " + "it, or run the tmux command manually.", + ) async def alist_windows( diff --git a/tests/experimental/mcp/conftest.py b/tests/experimental/mcp/conftest.py new file mode 100644 index 000000000..e076c1fef --- /dev/null +++ b/tests/experimental/mcp/conftest.py @@ -0,0 +1,18 @@ +"""Shared fixtures for the experimental MCP tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _hermetic_caller_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + """Disable the ``/proc`` parent walk by default so server builds are hermetic. + + ``build_*_server`` defaults its caller to ``CallerContext.discover()``, which + would otherwise walk the test host's process tree (host-dependent). Tests that + exercise discovery pass explicit readers/environ to ``discover`` and are + unaffected; tests that want a caller monkeypatch ``TMUX_PANE`` (the + process-env source, which wins before the walk). + """ + monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index cc67aa18a..c676e013b 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import dataclasses import typing as t import pytest @@ -28,6 +29,8 @@ caller_server_args, engine_socket, is_strict_caller, + socket_could_match, + socket_matches, ) from libtmux.experimental.mcp.vocabulary._resolve import resolve_origin @@ -348,3 +351,34 @@ def test_self_kill_refusals_live( monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) kill_session(engine, created.session_id) + + +# --------------------------------------------------------------------------- # +# Review fixes: ambient-engine scoping (S1) + per-op guard (S2) +# --------------------------------------------------------------------------- # +def test_ambient_engine_matches_only_process_env_caller() -> None: + """An unbound engine is the caller's server only for a process-env caller.""" + proc = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/s,1,2"}) + walked = dataclasses.replace(proc, source="parent-walk") + assert socket_could_match(None, proc) is True + assert socket_could_match(None, walked) is False + assert socket_matches(None, proc) is True + assert socket_matches(None, walked) is False + + +def test_op_kill_pane_is_guarded(monkeypatch: pytest.MonkeyPatch) -> None: + """The per-op kill surface is self-kill-guarded too (no bypass).""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + server = build_async_server( + SyncToAsyncEngine(ConcreteEngine()), events="off", expose_operations=True + ) + + async def main() -> None: + async with fastmcp.Client(server) as client: + await client.call_tool("op_kill_pane", {"target": "%9"}) + + with pytest.raises(ToolError, match="this MCP server"): + asyncio.run(main()) From 0107e49ceca6f63bfaf2dce7103810dc5df41699 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:52:57 -0500 Subject: [PATCH 070/223] Mcp(feat): Needle-free pane-output monitor why: Agents need to know when a command in a pane finishes without hard-coding a needle (regex/sentinel) the tool must guess, and without blocking the server. tmux stops emitting %output the instant a pane goes quiet, so idle-since-last-%output is a structural signal the agent interprets via the captured chunk plus pane metadata. what: - Add _settle.py pure core: decode_output (tmux octal), output_payload (per-pane filter, split not join so inner whitespace survives), and accumulate_until_settle (settle/byte/time/end fold over an injected async stream + clock). All doctested; no I/O, no fastmcp. - Add wait_for_output edge tool in events.py: folds decoded %output to a frozen MonitorResult, reads DoneMetadata (pane_dead/status, pane_current_command) so the agent disambiguates finished vs blocked. ctx.info for live partials; aclosing for cancellation safety; each call runs in its own task. - _ensure_attached: a bare tmux -C client emits no %output until attach-session, so attach (sticky per engine) before folding; raise on a failed attach so a stale session never yields a silent capture. - Tests: pure settle unit + cancellation (test_settle.py); offline integration + attach/dropped/done coverage (test_events.py); live end-to-end against real tmux (test_monitor_live.py). --- src/libtmux/experimental/mcp/_settle.py | 280 ++++++++++++++++++++ src/libtmux/experimental/mcp/events.py | 228 +++++++++++++++- tests/experimental/mcp/test_events.py | 224 ++++++++++++++++ tests/experimental/mcp/test_monitor_live.py | 73 +++++ tests/experimental/mcp/test_settle.py | 162 +++++++++++ 5 files changed, 966 insertions(+), 1 deletion(-) create mode 100644 src/libtmux/experimental/mcp/_settle.py create mode 100644 tests/experimental/mcp/test_monitor_live.py create mode 100644 tests/experimental/mcp/test_settle.py diff --git a/src/libtmux/experimental/mcp/_settle.py b/src/libtmux/experimental/mcp/_settle.py new file mode 100644 index 000000000..71d1d926d --- /dev/null +++ b/src/libtmux/experimental/mcp/_settle.py @@ -0,0 +1,280 @@ +r"""Needle-free settle accumulator for the pane-output monitor. + +A tmux pane stops emitting ``%output`` the instant it stops producing bytes, so +"no ``%output`` for ``settle_ms``" is a direct I/O-layer *quiet* signal -- no +regex, no sentinel injection, no assumed output format. This module is the pure, +framework-free core of that idea: a decoder for tmux's octal ``%output`` +escaping, a per-pane payload filter, and a fold over an injected async stream +that returns the moment the stream goes quiet (or a byte/time cap fires). + +It imports no MCP framework and touches no tmux connection, so every function +here carries an executable doctest driven by literal strings or a fake async +generator with an injected clock. The :mod:`~.events` edge maps a control-mode +engine's ``subscribe()`` stream onto these helpers. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + +SettleReason = t.Literal["settled", "time_cap", "byte_cap", "stream_end"] + + +@dataclass(frozen=True) +class SettleOutcome: + """Result of folding a decoded ``%output`` stream until the pane settles. + + Parameters + ---------- + text : str + The decoded bytes the pane produced during the watch (tail-preserving + prefix when ``truncated``). + reason : {"settled", "time_cap", "byte_cap", "stream_end"} + Why the fold stopped. ``settled`` means *stopped producing output*, not + *succeeded* -- the caller interprets the text. + byte_count : int + Size of ``text`` in bytes (capped at ``max_bytes``). + frame_count : int + Number of stream chunks folded in. + idle_ms_observed : int + Only meaningful when ``reason == "settled"``: the idle gap (``settle_ms``) + that triggered the stop. For other reasons it is the most recent + inter-chunk gap, or ``0`` if no chunk arrived -- do not read it as the + cause of the stop. + truncated : bool + Whether ``max_bytes`` clipped the text (tail kept). + """ + + text: str + reason: SettleReason + byte_count: int + frame_count: int + idle_ms_observed: int + truncated: bool + + +def decode_output(payload: str) -> str: + r"""Decode tmux's backslash-octal ``%output`` escaping. + + tmux escapes any byte below ``0x20`` and a literal backslash as ``\ooo`` (one + to three octal digits). A backslash not followed by an octal digit -- or a + lone trailing backslash -- passes through verbatim rather than raising. + + Parameters + ---------- + payload : str + The raw ``%output`` data body, after the ``%output %N `` prefix. + + Returns + ------- + str + The decoded text. + + Examples + -------- + A newline and a tab decode from their octal escapes: + + >>> decode_output('a\\012b') + 'a\nb' + >>> decode_output('tab\\011x') + 'tab\tx' + + An escaped backslash collapses to one, and plain text is untouched: + + >>> decode_output('a\\134b') + 'a\\b' + >>> decode_output('plain text, spaces kept') + 'plain text, spaces kept' + + A lone trailing backslash passes through: + + >>> decode_output('trailing\\') + 'trailing\\' + """ + out: list[str] = [] + i, n = 0, len(payload) + while i < n: + ch = payload[i] + if ch == "\\" and i + 1 < n and payload[i + 1] in "01234567": + j = i + 1 + while j < n and j - i <= 3 and payload[j] in "01234567": + j += 1 + out.append(chr(int(payload[i + 1 : j], 8))) + i = j + else: + out.append(ch) + i += 1 + return "".join(out) + + +def output_payload(raw: str, pane_id: str) -> str | None: + r"""Return the decoded ``%output`` payload for *pane_id*, else ``None``. + + Slices the data body with ``raw.split(" ", 2)[2]`` -- **not** + ``" ".join(args[1:])``, which would collapse runs of internal whitespace + because the notification parser split the whole line on single spaces. + + Parameters + ---------- + raw : str + A ``ControlNotification.raw`` line. + pane_id : str + The concrete pane id (``%N``) to match. + + Returns + ------- + str or None + The decoded payload, or ``None`` when *raw* is not an ``%output`` frame + for *pane_id*. + + Examples + -------- + Internal whitespace is preserved exactly: + + >>> output_payload('%output %1 a b', '%1') + 'a b' + + A frame for another pane, or a non-output frame, is ignored: + + >>> output_payload('%output %2 x', '%1') is None + True + >>> output_payload('%window-add @3', '%1') is None + True + """ + parts = raw.split(" ", 2) + if len(parts) < 3 or parts[0] != "%output" or parts[1] != pane_id: + return None + return decode_output(parts[2]) + + +async def accumulate_until_settle( + frames: AsyncGenerator[str, None], + *, + settle_ms: int, + timeout_ms: int, + max_bytes: int, + now: Callable[[], float] = time.monotonic, +) -> SettleOutcome: + r"""Fold a stream of decoded chunks until the pane settles. + + Resets an idle window on each chunk and returns ``reason='settled'`` when no + chunk arrives for ``settle_ms``; ``'byte_cap'`` at ``max_bytes`` (tail + preserved); ``'time_cap'`` when the overall ``timeout_ms`` budget is spent; + ``'stream_end'`` when *frames* is exhausted. The wall-clock budget reads + *now* (inject a scripted clock for deterministic ``time_cap`` tests); the idle + window uses a real :func:`asyncio.wait_for`, so a fake stream that simply + suspends settles deterministically with no scripted sleeps. The stream is + closed via :func:`contextlib.aclosing` on every exit, including cancellation. + + Parameters + ---------- + frames : AsyncGenerator[str, None] + The decoded per-pane output chunks. + settle_ms : int + Idle gap that counts as "settled". + timeout_ms : int + Overall wall-clock budget. + max_bytes : int + Byte cap; the returned text keeps the tail. + now : Callable[[], float] + Monotonic clock source, injectable for tests. + + Returns + ------- + SettleOutcome + The folded text plus the stop reason and counters. + + Examples + -------- + A pane that emits two chunks then goes quiet settles on the idle window: + + >>> import asyncio + >>> async def quiet_after_two(): + ... yield "hello " + ... yield "world" + ... await asyncio.Event().wait() # never another chunk -> idle fires + >>> out = asyncio.run( + ... accumulate_until_settle( + ... quiet_after_two(), settle_ms=10, timeout_ms=1000, max_bytes=4096 + ... ) + ... ) + >>> out.text, out.reason, out.byte_count + ('hello world', 'settled', 11) + + A flood past the byte cap truncates (tail-preserving) and stops: + + >>> async def flood(): + ... for _ in range(100): + ... yield "abcde" + >>> out = asyncio.run( + ... accumulate_until_settle( + ... flood(), settle_ms=50, timeout_ms=1000, max_bytes=8 + ... ) + ... ) + >>> out.reason, out.byte_count, out.truncated + ('byte_cap', 8, True) + + An exhausted stream ends cleanly: + + >>> async def two_then_done(): + ... yield "a" + ... yield "b" + >>> asyncio.run( + ... accumulate_until_settle( + ... two_then_done(), settle_ms=50, timeout_ms=1000, max_bytes=64 + ... ) + ... ).reason + 'stream_end' + """ + buf: list[str] = [] + byte_count = frame_count = 0 + idle_ms_observed = 0 + reason: SettleReason = "stream_end" + settle_s = settle_ms / 1000.0 + deadline = now() + timeout_ms / 1000.0 + async with contextlib.aclosing(frames): + while True: + remaining = deadline - now() + if remaining <= 0: + reason = "time_cap" + break + wait_s = min(settle_s, remaining) + start = now() + try: + chunk = await asyncio.wait_for(frames.__anext__(), timeout=wait_s) + except asyncio.TimeoutError: + if now() - deadline >= 0: # the wall-clock cap, not the idle gap + reason = "time_cap" + else: # idle window elapsed -> the pane went quiet + idle_ms_observed = int(settle_s * 1000) + reason = "settled" + break + except StopAsyncIteration: + reason = "stream_end" + break + idle_ms_observed = int((now() - start) * 1000) + buf.append(chunk) + frame_count += 1 + byte_count += len(chunk.encode()) + if byte_count >= max_bytes: + reason = "byte_cap" + break + text = "".join(buf) + truncated = reason == "byte_cap" + if truncated: # keep the tail -- "did it finish" lives at the end + text = text.encode()[-max_bytes:].decode(errors="replace") + return SettleOutcome( + text=text, + reason=reason, + byte_count=min(byte_count, max_bytes), + frame_count=frame_count, + idle_ms_observed=idle_ms_observed, + truncated=truncated, + ) diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index 2869884ce..c62f5a118 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -25,14 +25,21 @@ import asyncio import collections import contextlib +import time import typing as t +from dataclasses import dataclass from fastmcp import Context from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.mcp._settle import ( + SettleReason, + accumulate_until_settle, + output_payload, +) if t.TYPE_CHECKING: - from collections.abc import AsyncIterator, Sequence + from collections.abc import AsyncGenerator, AsyncIterator, Sequence from fastmcp import FastMCP @@ -43,6 +50,62 @@ _RING_SIZE = 1024 +# tmux format read once at settle to fill DoneMetadata (tab-joined, one round-trip). +_DONE_FORMAT = "\t".join( + ( + "#{pane_dead}", + "#{pane_dead_status}", + "#{pane_dead_signal}", + "#{pane_current_command}", + "#{cursor_y}", + "#{history_size}", + "#{pane_in_mode}", + ), +) + + +@dataclass(frozen=True) +class DoneMetadata: + """Needle-free done-heuristics, read once at settle for the agent to interpret. + + A ``pane_dead`` pane with a ``pane_dead_status`` is a *hard* "process exited" + signal; ``pane_current_command`` reverting to a shell is a *soft* "command + finished" signal. The screen-state fields add context without claiming intent. + """ + + pane_dead: bool + pane_dead_status: int | None + pane_dead_signal: str | None + pane_current_command: str | None + cursor_y: int | None + history_size: int | None + pane_in_mode: bool + + +@dataclass(frozen=True) +class MonitorResult: + """What ``wait_for_output`` returns; auto-serialized to structured content. + + ``reason`` is itself a signal: ``settled`` (the pane went quiet -- finished + *or* blocked on input; ``done`` disambiguates), ``time_cap`` (still producing + when the budget ran out; a partial chunk is returned), ``byte_cap`` (flooded, + ``truncated``), ``stream_end`` (the notification stream ended). + ``idle_ms_observed`` is only meaningful when ``reason == "settled"``; + ``snapshot_lines`` is ``None`` when the call passed ``snapshot=False``. + """ + + pane_id: str + reason: SettleReason + captured_text: str + byte_count: int + frame_count: int + idle_ms_observed: int + elapsed_ms: int + truncated: bool + dropped: int + done: DoneMetadata + snapshot_lines: tuple[str, ...] | None + class _StreamEngine(t.Protocol): """An async engine that also exposes a ``subscribe()`` notification stream. @@ -145,6 +208,7 @@ def register_events( _register_push(mcp, stream, source=source) if mode in ("pull", "both"): _register_pull(mcp, stream) + _register_monitor(mcp, stream) def _register_push( @@ -231,3 +295,165 @@ async def poll_events(since: int = 0) -> dict[str, t.Any]: annotations=ToolAnnotations(title="poll_events", readOnlyHint=True), ) mcp.add_tool(tool) + + +async def _read_done(engine: _StreamEngine, pane_id: str) -> DoneMetadata: + """Fill :class:`DoneMetadata` for *pane_id* in one ``display-message`` read.""" + from libtmux.experimental.mcp.vocabulary.server import adisplay_message + + text = (await adisplay_message(engine, pane_id, _DONE_FORMAT)).text + fields = (text.split("\t") + [""] * 7)[:7] + + def _as_int(value: str) -> int | None: + value = value.strip() + if not value: + return None + try: + return int(value) + except ValueError: + return None + + def _as_str(value: str) -> str | None: + return value.strip() or None + + return DoneMetadata( + pane_dead=fields[0].strip() == "1", + pane_dead_status=_as_int(fields[1]), + pane_dead_signal=_as_str(fields[2]), + pane_current_command=_as_str(fields[3]), + cursor_y=_as_int(fields[4]), + history_size=_as_int(fields[5]), + pane_in_mode=fields[6].strip() == "1", + ) + + +async def _ensure_attached(engine: _StreamEngine, session_id: str) -> None: + """Attach the control client to *session_id* so its panes emit ``%output``. + + A bare ``tmux -C`` control client receives **no** ``%output`` until it + attaches to a session (a server-global notification like ``%window-add`` + arrives without attaching, but per-pane output does not). Attaching also + triggers a one-time screen redraw, so a *successful* attachment is tracked + per engine: re-watching the same session does not re-attach or redraw again. + + Raises on a failed attach (stale or killed session) instead of caching, so + the caller gets a clear error rather than a silently empty capture and a + later call can retry. + """ + if getattr(engine, "_attached_session", None) == session_id: + return + result = await engine.run( + CommandRequest.from_args("attach-session", "-t", session_id), + ) + if result.returncode != 0: + detail = " ".join(result.stderr) or "attach-session failed" + msg = f"cannot watch {session_id}: {detail}" + raise RuntimeError(msg) + engine._attached_session = session_id # type: ignore[attr-defined] + + +def _register_monitor(mcp: FastMCP, engine: _StreamEngine) -> None: + """Register the ``wait_for_output`` needle-free settle monitor tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + async def wait_for_output( + ctx: Context, + target: str, + settle_ms: int = 750, + timeout: float = 30.0, + max_bytes: int = 131072, + stream_partials: bool = False, + snapshot: bool = True, + ) -> MonitorResult: + """Watch one pane's live output and return when it goes quiet (settles). + + Needle-free: accumulates the bytes the pane *produces* and returns the + instant it stays idle for ``settle_ms`` -- or the wall-clock ``timeout`` + (seconds) or ``max_bytes`` cap fires, or the stream ends. No regex, no + sentinel injection: read ``captured_text`` + ``done`` and decide what + happened. + + ``reason='settled'`` means *stopped producing output*, NOT *succeeded* -- + it cannot tell "finished, back to the shell" from "blocked on stdin". Use + ``done.pane_dead`` (+ ``done.pane_dead_status``) and + ``done.pane_current_command`` (a shell name) to disambiguate; + ``dropped``/``truncated`` warn the captured chunk may be incomplete. + + Set ``stream_partials=True`` to also push each chunk live as an MCP log + message. ``snapshot`` defaults to ``True``: at settle the rendered grid is + captured into ``snapshot_lines``; pass ``snapshot=False`` to skip that + capture, leaving ``snapshot_lines`` ``None``. While it runs it shares + tmux's single ``%output`` stream with ``watch_events`` / ``poll_events``; + it is bounded by the caps and short-lived, and each call runs in its own + task so it does not block other tools. The first watch on a session + attaches the control client (so its panes emit ``%output``), which draws + the current screen once into ``captured_text`` -- the clean rendered grid, + when requested, is in ``snapshot_lines``. + """ + from libtmux.experimental.mcp.target_resolver import resolve_target + from libtmux.experimental.mcp.vocabulary._resolve import ( + pane_id as resolve_pane_id, + reject_relative_special, + session_id_of, + ) + from libtmux.experimental.mcp.vocabulary.pane import acapture_pane + + reject_relative_special(resolve_target(target)) + pane = await resolve_pane_id(engine, target, None) + await _ensure_attached(engine, await session_id_of(engine, target, None)) + + dropped_before = getattr(engine, "dropped_notifications", 0) + started = time.monotonic() + + async def _frames() -> AsyncGenerator[str, None]: + async for notification in engine.subscribe(): + payload = output_payload(notification.raw, pane) + if payload is None: + continue + if stream_partials: + await ctx.info(payload) + yield payload + + outcome = await accumulate_until_settle( + _frames(), + settle_ms=settle_ms, + timeout_ms=int(timeout * 1000), + max_bytes=max_bytes, + ) + elapsed_ms = int((time.monotonic() - started) * 1000) + dropped = getattr(engine, "dropped_notifications", 0) - dropped_before + + done = await _read_done(engine, pane) + snapshot_lines: tuple[str, ...] | None = None + if snapshot: + captured = await acapture_pane( + engine, + pane, + join_wrapped=True, + trim_trailing=True, + ) + snapshot_lines = tuple(captured.lines) + + return MonitorResult( + pane_id=pane, + reason=outcome.reason, + captured_text=outcome.text, + byte_count=outcome.byte_count, + frame_count=outcome.frame_count, + idle_ms_observed=outcome.idle_ms_observed, + elapsed_ms=elapsed_ms, + truncated=outcome.truncated, + dropped=dropped, + done=done, + snapshot_lines=snapshot_lines, + ) + + tool = FunctionTool.from_function( + wait_for_output, + name="wait_for_output", + description="Watch a pane's output and return when it settles (needle-free)", + tags={"readonly", "events", "monitor"}, + annotations=ToolAnnotations(title="wait_for_output", readOnlyHint=True), + ) + mcp.add_tool(tool) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 4f8a7ef0e..92f4711fe 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -46,6 +46,69 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: _STREAM = (b"%window-add @3", b"%output %1 hi", b"%window-close @3") +_MON_STREAM = (b"%output %1 a b", b"%output %1 c", b"%window-add @9") + + +class InstrumentedEngine: + """A fake stream engine that records commands and scripts a few responses. + + Records every ``run`` argv in ``calls`` (so attach idempotence is assertable), + exposes a settable ``dropped_notifications`` counter, can inject an + ``attach-session`` failure, and can return a canned ``display-message`` line + for the done-heuristics format. + """ + + def __init__( + self, + raw: tuple[bytes, ...] = (), + *, + attach_returncode: int = 0, + done_line: str | None = None, + dropped_after: int = 0, + ) -> None: + self._raw = raw + self.calls: list[tuple[str, ...]] = [] + self.dropped_notifications = 0 + self._attached_session: str | None = None + self._attach_returncode = attach_returncode + self._done_line = done_line + self._dropped_after = dropped_after + + async def run(self, request: CommandRequest) -> CommandResult: + """Record the command and return a scripted result.""" + args = tuple(request.args) + self.calls.append(args) + if args and args[0] == "attach-session": + stderr = () if self._attach_returncode == 0 else ("can't find session",) + return CommandResult( + cmd=("tmux", *args), + returncode=self._attach_returncode, + stderr=stderr, + ) + if args and args[0] == "display-message": + fmt = args[-1] + if "pane_dead" in fmt and self._done_line is not None: + return CommandResult( + cmd=("tmux", *args), + returncode=0, + stdout=(self._done_line,), + ) + if "session_id" in fmt: + return CommandResult(cmd=("tmux", *args), returncode=0, stdout=("$1",)) + return CommandResult(cmd=("tmux", *args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield the fixed notification sequence, then bump the drop counter.""" + for raw in self._raw: + yield ControlNotification.parse(raw) + self.dropped_notifications += self._dropped_after def _tool_names(server: t.Any) -> set[str]: @@ -123,6 +186,166 @@ def test_both_registers_push_and_pull() -> None: assert {"watch_events", "poll_events"} <= names +def test_monitor_registered_when_streaming() -> None: + """wait_for_output is exposed whenever the engine streams, in any event mode.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="pull", + include_operations=False, + include_plan_tools=False, + ) + assert "wait_for_output" in _tool_names(server) + + +def test_monitor_settles_on_stream_end() -> None: + """wait_for_output folds per-pane output and returns when the stream ends. + + The decoded chunks preserve internal whitespace (``a b`` + ``c`` -> ``a bc``), + locking out the ``" ".join`` reconstruction bug; the non-output frame is + filtered. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool("wait_for_output", {"target": "%1"}) + return result.data + + data = asyncio.run(main()) + assert data.pane_id == "%1" + assert data.reason == "stream_end" + assert data.captured_text == "a bc" + assert data.frame_count == 2 + assert data.truncated is False + assert data.snapshot_lines == [] + assert data.done.pane_dead is False + + +def test_monitor_snapshot_false_omits_grid() -> None: + """snapshot=False leaves snapshot_lines None and skips the capture.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + InstrumentedEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "wait_for_output", + {"target": "%1", "snapshot": False}, + ) + return result.data + + data = asyncio.run(main()) + assert data.snapshot_lines is None + assert data.reason == "stream_end" + + +def test_monitor_reports_dropped_delta() -> None: + """The dropped field is the engine's overflow-counter delta during the watch.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + InstrumentedEngine(_MON_STREAM, dropped_after=5), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool("wait_for_output", {"target": "%1"}) + return result.data + + data = asyncio.run(main()) + assert data.dropped == 5 + + +def test_monitor_stream_partials_pushes_each_chunk() -> None: + """stream_partials=True pushes each decoded chunk as an MCP log message.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + logged: list[t.Any] = [] + + async def log_handler(message: t.Any) -> None: + # ctx.info wraps the payload as {"msg": ..., "extra": ...}. + data = message.data + logged.append(data["msg"] if isinstance(data, dict) else data) + + server = build_async_server( + InstrumentedEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> None: + async with fastmcp.Client(server, log_handler=log_handler) as client: + await client.call_tool( + "wait_for_output", + {"target": "%1", "stream_partials": True}, + ) + + asyncio.run(main()) + assert "a b" in logged + assert "c" in logged + + +def test_ensure_attached_raises_on_failed_attach() -> None: + """A failed attach raises and does not poison the per-engine cache.""" + from libtmux.experimental.mcp.events import _ensure_attached + + engine = InstrumentedEngine(attach_returncode=1) + with pytest.raises(RuntimeError, match="cannot watch"): + asyncio.run(_ensure_attached(engine, "$dead")) + assert getattr(engine, "_attached_session", None) is None + + +def test_ensure_attached_is_idempotent_per_session() -> None: + """Re-watching the same session attaches exactly once (no repeated redraw).""" + from libtmux.experimental.mcp.events import _ensure_attached + + engine = InstrumentedEngine() + + async def main() -> None: + await _ensure_attached(engine, "$1") + await _ensure_attached(engine, "$1") + + asyncio.run(main()) + attaches = [c for c in engine.calls if c and c[0] == "attach-session"] + assert len(attaches) == 1 + assert engine._attached_session == "$1" + + +def test_read_done_parses_display_message_fields() -> None: + """_read_done maps the tab-joined display-message into DoneMetadata.""" + from libtmux.experimental.mcp.events import _read_done + + done_line = "1\t137\tHUP\tbash\t3\t50\t1" + engine = InstrumentedEngine(done_line=done_line) + done = asyncio.run(_read_done(engine, "%1")) + assert done.pane_dead is True + assert done.pane_dead_status == 137 + assert done.pane_dead_signal == "HUP" + assert done.pane_current_command == "bash" + assert done.cursor_y == 3 + assert done.history_size == 50 + assert done.pane_in_mode is True + + def test_no_event_tools_without_a_stream() -> None: """A non-streaming engine registers no event tools, even when asked.""" from libtmux.experimental.engines import ConcreteEngine @@ -138,3 +361,4 @@ def test_no_event_tools_without_a_stream() -> None: names = _tool_names(server) assert "watch_events" not in names assert "poll_events" not in names + assert "wait_for_output" not in names diff --git a/tests/experimental/mcp/test_monitor_live.py b/tests/experimental/mcp/test_monitor_live.py new file mode 100644 index 000000000..4f55d5805 --- /dev/null +++ b/tests/experimental/mcp/test_monitor_live.py @@ -0,0 +1,73 @@ +"""End-to-end ``wait_for_output`` against a real tmux control-mode engine. + +Unlike the offline event tests, this drives the monitor through an in-process +FastMCP client over a live ``tmux -C`` connection: a real pane produces output, +and the tool folds the genuine ``%output`` firehose (octal-decoded) until the +pane goes quiet. Proves the needle-free settle path works against real tmux, not +just a scripted stream. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines.base import CommandRequest + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_wait_for_output_captures_real_output(session: Session) -> None: + """The monitor folds a real pane's output and settles when it goes quiet.""" + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = session.server + pane = session.active_window.active_pane + assert pane is not None + pane_id = pane.pane_id + assert pane_id is not None + + async def main() -> t.Any: + async with AsyncControlModeEngine.for_server(server) as engine: + mcp = build_async_server( + engine, + events="push", + include_operations=False, + include_plan_tools=False, + ) + async with fastmcp.Client(mcp) as client: + + async def produce() -> None: + # Let the monitor subscribe first, then make the pane emit. + await asyncio.sleep(0.3) + await engine.run( + CommandRequest.from_args( + "send-keys", + "-t", + pane_id, + "echo MONITOR_OK", + "Enter", + ), + ) + + producer = asyncio.ensure_future(produce()) + try: + result = await client.call_tool( + "wait_for_output", + {"target": pane_id, "settle_ms": 400, "timeout": 10.0}, + ) + finally: + await producer + return result.data + + data = asyncio.run(main()) + assert data.pane_id == pane_id + assert data.reason in ("settled", "byte_cap") + assert "MONITOR_OK" in data.captured_text + assert data.frame_count >= 1 diff --git a/tests/experimental/mcp/test_settle.py b/tests/experimental/mcp/test_settle.py new file mode 100644 index 000000000..9c93907fb --- /dev/null +++ b/tests/experimental/mcp/test_settle.py @@ -0,0 +1,162 @@ +"""The pure settle accumulator -- decoder, per-pane filter, and the fold. + +Driven offline with literal strings and fake async generators plus an injected +clock, so every stop reason (settled, byte_cap, time_cap, stream_end) and the +cancellation teardown are exercised deterministically without a real tmux ``-C`` +connection or ``pytest-asyncio``. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.mcp._settle import ( + accumulate_until_settle, + decode_output, + output_payload, +) + +if t.TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + + +def test_decode_output_octal_and_passthrough() -> None: + """Octal escapes decode; an escaped backslash collapses; plain text is kept.""" + assert decode_output("a\\012b") == "a\nb" + assert decode_output("tab\\011x") == "tab\tx" + assert decode_output("a\\134b") == "a\\b" + assert decode_output("plain, spaces kept") == "plain, spaces kept" + assert decode_output("trailing\\") == "trailing\\" + + +def test_output_payload_preserves_internal_whitespace() -> None: + """The per-pane filter slices the data body without collapsing inner spaces.""" + assert output_payload("%output %1 a b", "%1") == "a b" + assert output_payload("%output %2 x", "%1") is None + assert output_payload("%window-add @3", "%1") is None + + +def test_accumulate_settles_on_idle() -> None: + """A pane that emits then goes quiet returns reason='settled'.""" + + async def quiet_after_two() -> AsyncGenerator[str, None]: + yield "hello " + yield "world" + await asyncio.Event().wait() + + out = asyncio.run( + accumulate_until_settle( + quiet_after_two(), + settle_ms=10, + timeout_ms=2000, + max_bytes=4096, + ), + ) + assert out.reason == "settled" + assert out.text == "hello world" + assert out.byte_count == 11 + assert out.frame_count == 2 + assert out.truncated is False + # On 'settled', idle_ms_observed is exactly the settle_ms threshold. + assert out.idle_ms_observed == 10 + + +def test_accumulate_byte_cap_keeps_tail() -> None: + """A flood past max_bytes truncates, preserving the tail.""" + + async def flood() -> AsyncGenerator[str, None]: + for _ in range(100): + yield "abcde" + + out = asyncio.run( + accumulate_until_settle( + flood(), + settle_ms=50, + timeout_ms=2000, + max_bytes=8, + ), + ) + assert out.reason == "byte_cap" + assert out.byte_count == 8 + assert out.truncated is True + assert out.text == "cdeabcde" # last 8 bytes of "abcdeabcde" + + +def test_accumulate_stream_end() -> None: + """An exhausted stream returns reason='stream_end'.""" + + async def two_then_done() -> AsyncGenerator[str, None]: + yield "a" + yield "b" + + out = asyncio.run( + accumulate_until_settle( + two_then_done(), + settle_ms=50, + timeout_ms=2000, + max_bytes=64, + ), + ) + assert out.reason == "stream_end" + assert out.text == "ab" + + +def test_accumulate_time_cap_with_scripted_clock() -> None: + """A slow-but-never-idle pane hits the wall-clock cap via the injected clock.""" + + def make_clock(step: float = 0.5) -> Callable[[], float]: + state = {"t": -step} + + def clock() -> float: + state["t"] += step + return state["t"] + + return clock + + async def forever() -> AsyncGenerator[str, None]: + while True: + yield "x" + + out = asyncio.run( + accumulate_until_settle( + forever(), + settle_ms=50, + timeout_ms=1000, + max_bytes=100000, + now=make_clock(), + ), + ) + assert out.reason == "time_cap" + assert out.frame_count >= 1 + + +def test_accumulate_closes_stream_on_cancel() -> None: + """Cancelling the fold closes the stream, so no consumer is leaked.""" + closed = {"value": False} + + async def blocking() -> AsyncGenerator[str, None]: + try: + yield "first" + await asyncio.Event().wait() + finally: + closed["value"] = True + + async def main() -> bool: + task = asyncio.ensure_future( + accumulate_until_settle( + blocking(), + settle_ms=10000, + timeout_ms=10000, + max_bytes=4096, + ), + ) + await asyncio.sleep(0.05) # let the fold park on the blocking stream + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return closed["value"] + + assert asyncio.run(main()) is True From e7dfb32e63fff198e577e09fdb0ae569c01fb567 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 19:06:59 -0500 Subject: [PATCH 071/223] Mcp(feat): Make wait_for_output discoverable why: A capable agent asked to run tests in a pane and wait fell back to sleep + capture_pane polling -- it never found wait_for_output because the tool's surface said "watch pane output / settles / needle-free", not the agent's intent "run a command and wait for it to finish". The capability shipped in round 4; this surfaces it. what: - _instructions(): add a run-a-command-and-wait paragraph naming the split_pane/send_input -> wait_for_output workflow, the test/build use case, the "prefer over sleep + capture_pane polling" steer, and the "settled is not success" caveat. Gate it (events_enabled) so the sync server never names a tool it does not register. - wait_for_output: enrich description= with discovery vocabulary (completion, exit/return code, success/failed) and add a NumPy Parameters section -- FastMCP parses it into per-param schema descriptions even when description= is overridden. - docs: rename the colliding sync polling helper wait_for_output -> wait_for_text and point agents at the event-backed tool. - tests: lock the discoverable wording -- instructions name the tool + workflow + anti-polling steer; tool metadata carries the vocab + per param descriptions; events=off omits the live-output guidance. --- docs/topics/automation_patterns.md | 9 +- src/libtmux/experimental/mcp/events.py | 82 +++++++++----- .../experimental/mcp/fastmcp_adapter.py | 101 ++++++++++++------ tests/experimental/mcp/test_events.py | 75 +++++++++++++ 4 files changed, 206 insertions(+), 61 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba9641108..df0aa294f 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -118,13 +118,18 @@ command that never finishes can't hang your script forever. The `poll_interval` the latency/work trade in one knob: poll faster to react sooner, slower to spare tmux the round-trips. +> **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: @@ -135,7 +140,7 @@ the round-trips. ... 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 diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index c62f5a118..ed8fa2c68 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -366,30 +366,54 @@ async def wait_for_output( stream_partials: bool = False, snapshot: bool = True, ) -> MonitorResult: - """Watch one pane's live output and return when it goes quiet (settles). - - Needle-free: accumulates the bytes the pane *produces* and returns the - instant it stays idle for ``settle_ms`` -- or the wall-clock ``timeout`` - (seconds) or ``max_bytes`` cap fires, or the stream ends. No regex, no - sentinel injection: read ``captured_text`` + ``done`` and decide what - happened. - - ``reason='settled'`` means *stopped producing output*, NOT *succeeded* -- - it cannot tell "finished, back to the shell" from "blocked on stdin". Use - ``done.pane_dead`` (+ ``done.pane_dead_status``) and - ``done.pane_current_command`` (a shell name) to disambiguate; - ``dropped``/``truncated`` warn the captured chunk may be incomplete. - - Set ``stream_partials=True`` to also push each chunk live as an MCP log - message. ``snapshot`` defaults to ``True``: at settle the rendered grid is - captured into ``snapshot_lines``; pass ``snapshot=False`` to skip that - capture, leaving ``snapshot_lines`` ``None``. While it runs it shares - tmux's single ``%output`` stream with ``watch_events`` / ``poll_events``; - it is bounded by the caps and short-lived, and each call runs in its own - task so it does not block other tools. The first watch on a session - attaches the control client (so its panes emit ``%output``), which draws - the current screen once into ``captured_text`` -- the clean rendered grid, - when requested, is in ``snapshot_lines``. + """Run a command and wait for it to finish; watch a pane until it settles. + + Use this to run a long-running command -- a test run (``uv run pytest``), a + build, an install, a server coming up -- and wait for the result instead of + polling with sleep + capture_pane. Typical flow: ``send_input`` the command + to a pane (``enter=True``), then call ``wait_for_output`` on that same pane. + It folds the bytes the pane *produces* and returns the instant it stays idle + for ``settle_ms`` -- or ``timeout`` / ``max_bytes`` fires, or the stream + ends. Needle-free: no regex, no sentinel injection. + + **Settled is not success.** ``reason='settled'`` means the pane stopped + producing output -- it cannot, on its own, tell "finished, back to the + shell" from "blocked waiting on stdin". To confirm the command exited, read + ``done.pane_dead`` with ``done.pane_dead_status`` (the process exit code; 0 + is success) and ``done.pane_current_command`` (a shell name means idle). + ``dropped`` / ``truncated`` warn the captured chunk may be incomplete. + + While it runs it shares tmux's single ``%output`` stream with + ``watch_events`` / ``poll_events``; it is bounded by the caps and + short-lived, and each call runs in its own task so it does not block other + tools. The first watch on a session attaches the control client (so its + panes emit ``%output``), which draws the current screen once into + ``captured_text`` -- the clean rendered grid, when requested, is in + ``snapshot_lines``. + + Parameters + ---------- + target : str + The pane to watch: a tmux id (``%pane``, ``@window``, ``$session``), a + name, or ``session:window.pane``. Resolve directional specials to a + concrete ``%N`` first. + settle_ms : int + Idle time in milliseconds with no new output before the pane is treated + as done (default 750). Lower returns sooner but risks a false settle + while a command pauses; raise it for chatty or bursty commands. + timeout : float + Wall-clock seconds to wait before giving up (default 30.0). Raise it for + slow test suites or heavy builds; on expiry ``reason`` reports the cap. + max_bytes : int + Cap on captured output bytes (default 131072). On overflow the watch + returns early with ``truncated`` set; raise it to keep more output. + stream_partials : bool + When ``True``, also push each output chunk live as an MCP log message + for real-time progress on long runs (default ``False``). + snapshot : bool + When ``True`` (default), capture the rendered pane grid into + ``snapshot_lines`` at settle; ``False`` skips that extra capture and + leaves ``snapshot_lines`` ``None``. """ from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._resolve import ( @@ -452,7 +476,15 @@ async def _frames() -> AsyncGenerator[str, None]: tool = FunctionTool.from_function( wait_for_output, name="wait_for_output", - description="Watch a pane's output and return when it settles (needle-free)", + description=( + "Run a command and wait for it to finish (command completion): watch " + "one pane's live output and return when it goes quiet (settles). Use " + "after send_input to wait for long-running tests/builds/installs " + "instead of sleep + capture_pane polling. Needle-free (no " + "regex/sentinel); read captured_text and the done metadata (pane_dead, " + "pane_dead_status = exit / return code, 0 is success) to tell whether " + "it finished or failed." + ), tags={"readonly", "events", "monitor"}, annotations=ToolAnnotations(title="wait_for_output", readOnlyHint=True), ) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 40ce25acb..915f25511 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -129,39 +129,68 @@ def _agent_context_segment(ctx: CallerContext) -> str: ) -def _instructions(ctx: CallerContext) -> str: - """Compose the server instructions, woven with the live caller context.""" - return "\n\n".join( - ( - "This MCP drives a real tmux server through typed tools: sessions, " - "windows, panes, terminal scrollback, send-keys, copy-mode buffers. " - "Targets accept tmux ids (%pane, @window, $session), names, or " - "'session:window.pane'.", - "When to invoke: managing tmux panes/windows/sessions; reading " - "terminal scrollback (capture_pane/grep_pane/search_panes); sending " - "keystrokes to a running shell or REPL (send_input); copy-mode and " - "paste-buffer work; operating on a pane relative to another or to you " - "(capture_relative_pane/grep_relative_pane).", - "Do NOT invoke for: editor panes you edit via file tools; browser tabs " - "or web content; GUI application windows; notebook cells; any non-tmux " - "terminal surface. tmux only sees terminal panes -- it cannot read a " - "browser or GUI app.", - "Prefer a concrete %N pane id; resolve relative or caller-relative " - "targets to a concrete %N before capture/send. Never hand a directional " - "special target ({up-of}/{down-of}/{left-of}/{right-of}) to " - "capture_pane/grep_pane/send_input -- those resolve against THIS MCP's " - "control client, not your pane; use capture_relative_pane / " - "grep_relative_pane / resolve_relative_pane instead.", - _agent_context_segment(ctx), - "list_panes/list_windows/show_options query tmux metadata (format " - "fields); grep_pane (one pane) and search_panes (across panes) search " - "terminal text (scrollback). Pick the right one for 'which pane shows X'.", - "The curated tools cover most needs; the per-operation surface (op_*) " - "and the plan tools (preview_plan/execute_plan/result_schema/" - "build_workspace) are power-use; watch_events streams live notifications " - "on a control-mode engine.", - ), +def _instructions(ctx: CallerContext, *, events_enabled: bool = False) -> str: + """Compose the server instructions, woven with the live caller context. + + *events_enabled* gates the live-output guidance (``wait_for_output`` / + ``watch_events``), which is registered only on a streaming control-mode + server -- the sync server omits it so it never names a tool it lacks. + """ + closer = ( + "The curated tools cover most needs; the per-operation surface (op_*) " + "and the plan tools (preview_plan/execute_plan/result_schema/" + "build_workspace) are power-use." ) + if events_enabled: + closer += ( + " For live output: wait_for_output waits for one pane to settle " + "(run-a-command-and-wait); watch_events/poll_events stream/buffer raw " + "control-mode notifications across the server." + ) + segments = [ + "This MCP drives a real tmux server through typed tools: sessions, " + "windows, panes, terminal scrollback, send-keys, copy-mode buffers. " + "Targets accept tmux ids (%pane, @window, $session), names, or " + "'session:window.pane'.", + "When to invoke: managing tmux panes/windows/sessions; reading " + "terminal scrollback (capture_pane/grep_pane/search_panes); sending " + "keystrokes to a running shell or REPL (send_input); copy-mode and " + "paste-buffer work; operating on a pane relative to another or to you " + "(capture_relative_pane/grep_relative_pane).", + "Do NOT invoke for: editor panes you edit via file tools; browser tabs " + "or web content; GUI application windows; notebook cells; any non-tmux " + "terminal surface. tmux only sees terminal panes -- it cannot read a " + "browser or GUI app.", + "Prefer a concrete %N pane id; resolve relative or caller-relative " + "targets to a concrete %N before capture/send. Never hand a directional " + "special target ({up-of}/{down-of}/{left-of}/{right-of}) to " + "capture_pane/grep_pane/send_input -- those resolve against THIS MCP's " + "control client, not your pane; use capture_relative_pane / " + "grep_relative_pane / resolve_relative_pane instead.", + _agent_context_segment(ctx), + ] + if events_enabled: + segments.append( + "Run a command and wait for it to finish / for completion " + "(long-running builds, test runs like `uv run pytest`, installs, a " + "server reaching ready): split_pane or pick a pane, send_input the " + "command (enter=True), then call wait_for_output on that same pane -- " + "it folds the live output and returns when the pane goes quiet " + "(settles), needle-free (no regex, no sentinel). Prefer this over " + "polling with sleep + capture_pane: wait_for_output is event-backed, " + "returns the captured_text, and reports done.pane_dead / " + "done.pane_dead_status (process exit / return code) plus " + "done.pane_current_command so you can tell finished from " + "blocked-on-input. Settled means output stopped, not that the command " + "succeeded or failed -- read the done metadata to confirm exit status.", + ) + segments.append( + "list_panes/list_windows/show_options query tmux metadata (format " + "fields); grep_pane (one pane) and search_panes (across panes) search " + "terminal text (scrollback). Pick the right one for 'which pane shows X'.", + ) + segments.append(closer) + return "\n\n".join(segments) def _summary(doc: str | None) -> str | None: @@ -591,11 +620,15 @@ def build_async_server( """ from fastmcp import FastMCP - from libtmux.experimental.mcp.events import register_events + from libtmux.experimental.mcp.events import _supports_stream, register_events ctx = caller if caller is not None else CallerContext.discover() _stash_caller(engine, ctx) - mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) + events_enabled = events != "off" and _supports_stream(engine) + mcp: FastMCP = FastMCP( + name=name, + instructions=instructions or _instructions(ctx, events_enabled=events_enabled), + ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) register_caller_context(mcp, ctx) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 92f4711fe..bfc69b313 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -346,6 +346,81 @@ def test_read_done_parses_display_message_fields() -> None: assert done.pane_in_mode is True +def test_instructions_surface_wait_for_output() -> None: + """The server instructions name the run-a-command-and-wait workflow. + + If this fails, the discoverable wording drifted -- update BOTH the instruction + text in fastmcp_adapter.py AND these assertions intentionally. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + text = server.instructions or "" + assert "wait_for_output" in text # the tool by name + assert "send_input" in text # the workflow pair + assert "completion" in text # the run-a-command-and-wait intent + assert "pytest" in text # the long-running / test use case + assert "sleep + capture_pane" in text # the anti-polling steer + assert "Settled" in text # settled-is-not-success caveat + + +def test_instructions_omit_wait_for_output_without_streaming() -> None: + """events='off' (no wait_for_output tool) drops the live-output guidance. + + The instructions must not name a tool the server did not register. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="off", + include_operations=False, + include_plan_tools=False, + ) + text = server.instructions or "" + assert "wait_for_output" not in text + assert "watch_events" not in text + + +def test_wait_for_output_metadata_is_discoverable() -> None: + """wait_for_output's description + per-param schema carry the search vocabulary. + + If this fails, the discoverable wording drifted -- update BOTH the description / + docstring in events.py AND these assertions intentionally. The per-param + descriptions also prove FastMCP parsed the NumPy ``Parameters`` section. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return {tool.name: tool for tool in await client.list_tools()} + + by_name = asyncio.run(main()) + tool = by_name["wait_for_output"] + description = tool.description or "" + assert "wait" in description + assert "finish" in description + assert "completion" in description + assert "sleep + capture_pane" in description + + props = tool.inputSchema["properties"] + for param in ("target", "settle_ms", "timeout", "max_bytes", "stream_partials"): + assert props[param].get("description"), f"{param} missing param description" + assert "idle" in props["settle_ms"]["description"].lower() + + def test_no_event_tools_without_a_stream() -> None: """A non-streaming engine registers no event tools, even when asked.""" from libtmux.experimental.engines import ConcreteEngine From 68ef311bed9791a8aa2691f3fdb69d6aebb1cdc0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 19:10:14 -0500 Subject: [PATCH 072/223] Mcp(fix): Close self-kill guard deferrals why: The self-kill guards left two known gaps: the per-op (op_*) kill surface skipped the others=True sibling case, and the conservative socket match relied on path reconstruction that diverges on macOS. what: - Add an authoritative conservative_socket() that queries the engine's #{socket_path} for a -L name or ambient socket, so the guard's socket scoping survives a macOS $TMUX_TMPDIR divergence; an explicit -S path is used as-is. - Lift the others=True guards (guard_kill_other_panes / _windows) into _resolve so the curated tools and guard_destructive_op share them; the per-op op_kill_pane/op_kill_window now refuse killing the caller's sibling pane/window too. - Cover with offline (conservative_socket) and live (curated + per-op others=True) regression tests. --- .../experimental/mcp/vocabulary/_resolve.py | 105 +++++++++++++++++- .../experimental/mcp/vocabulary/pane.py | 32 +----- .../experimental/mcp/vocabulary/window.py | 42 +------ tests/experimental/mcp/test_caller.py | 80 +++++++++++++ 4 files changed, 183 insertions(+), 76 deletions(-) diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py index 2de5c57e9..dbbcf29db 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_resolve.py +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -262,6 +262,32 @@ async def caller_session_or_none( return None +async def conservative_socket( + engine: AsyncTmuxEngine, + version: str | None, +) -> str | None: + """Resolve the engine's socket for a conservative caller comparison. + + An explicit ``-S`` path is authoritative as-is. For a ``-L`` name or the + ambient socket, asks tmux for ``#{socket_path}`` -- the path tmux actually + uses -- so a macOS ``$TMUX_TMPDIR`` divergence under launchd cannot fool the + reconstruction; falls back to the static selector when the query fails. + """ + static = engine_socket(engine) + if static is not None and "/" in static: + return static + try: + result = await arun( + DisplayMessage(message="#{socket_path}"), + engine, + version=version, + ) + result.raise_for_status() + except TmuxCommandError: + return static + return result.text.strip() or static + + async def guard_self_kill( engine: AsyncTmuxEngine, *, @@ -283,7 +309,7 @@ async def guard_self_kill( caller = caller_of(engine) if not caller.in_tmux or caller.pane_id is None: return - if not socket_could_match(engine_socket(engine), caller): + if not socket_could_match(await conservative_socket(engine, version), caller): return if pane is not None and caller.pane_id == pane: raise_target_hint( @@ -306,20 +332,87 @@ async def guard_self_kill( ) +async def guard_kill_other_panes( + engine: AsyncTmuxEngine, + target_pane: str, + version: str | None, +) -> None: + """Refuse ``kill_pane(others=True)`` when the caller is a sibling of the target. + + ``others=True`` keeps the target and kills every other pane in its window, so + the danger is the caller pane being one of those siblings (not the target). + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id or caller.pane_id == target_pane: + return + if not socket_could_match(await conservative_socket(engine, version), caller): + return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None: + return + target_window = await window_id(engine, PaneId(target_pane), version) + if caller_window == target_window: + raise_target_hint( + f"refusing to kill the other panes of window {target_window}: pane " + f"{caller.pane_id} runs this MCP server. Kill panes individually " + f"(excluding {caller.pane_id}), or run the tmux command manually.", + ) + + +async def guard_kill_other_windows( + engine: AsyncTmuxEngine, + target: str | Target, + target_window: str, + version: str | None, +) -> None: + """Refuse ``kill_window(others=True)`` when the caller is a same-session sibling. + + ``others=True`` keeps the target window and kills every other window in its + session, so the danger is the caller's window being one of those siblings. + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id: + return + if not socket_could_match(await conservative_socket(engine, version), caller): + return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None or caller_window == target_window: + return # caller not on this server, or it is the kept target window + target_session = await session_id_of(engine, target, version) + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is None or caller_session != target_session: + return + raise_target_hint( + f"refusing to kill the other windows of session {caller_session}: window " + f"{caller_window} holds this MCP server's pane {caller.pane_id}. Exclude " + "it, or run the tmux command manually.", + ) + + async def guard_destructive_op(engine: AsyncTmuxEngine, operation: t.Any) -> None: """Apply the self-kill guard to a per-op kill/respawn operation, by kind. - Closes the gap where the ``op_*`` per-operation surface dispatches around the - curated guard. The ``others=True`` sibling case is not fully covered here -- - prefer the curated kill tools when killing "all others". + Covers the ``op_*`` per-operation surface, including the ``others=True`` + sibling case (which keeps the target and kills its neighbours). """ target = operation.target if target is None: return kind = operation.kind - if kind in ("kill_pane", "respawn_pane"): + others = bool(getattr(operation, "others", False)) + if kind == "respawn_pane": await guard_self_kill(engine, pane=await pane_id(engine, target, None)) + elif kind == "kill_pane": + target_pane = await pane_id(engine, target, None) + if others: + await guard_kill_other_panes(engine, target_pane, None) + else: + await guard_self_kill(engine, pane=target_pane) elif kind == "kill_window": - await guard_self_kill(engine, window=await window_id(engine, target, None)) + target_window = await window_id(engine, target, None) + if others: + await guard_kill_other_windows(engine, target, target_window, None) + else: + await guard_self_kill(engine, window=target_window) elif kind == "kill_session": await guard_self_kill(engine, session=await session_id_of(engine, target, None)) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 083922f2f..032312835 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -24,7 +24,6 @@ from libtmux.experimental.mcp.vocabulary._caller import ( engine_socket, is_strict_caller, - socket_could_match, ) from libtmux.experimental.mcp.vocabulary._geometry import ( Corner, @@ -37,7 +36,7 @@ DIR_FLAG, active_pane_id, caller_of, - caller_window_or_none, + guard_kill_other_panes, guard_self_kill, opt_target, pane_id, @@ -406,7 +405,7 @@ async def akill_pane( reject_relative_special(resolved) target_pane = await pane_id(engine, target, version) if others: - await _guard_kill_others(engine, target_pane, version) + await guard_kill_other_panes(engine, target_pane, version) else: await guard_self_kill(engine, pane=target_pane, version=version) ( @@ -418,33 +417,6 @@ async def akill_pane( ).raise_for_status() -async def _guard_kill_others( - engine: AsyncTmuxEngine, - target_pane: str, - version: str | None, -) -> None: - """Refuse ``kill_pane(others=True)`` when the caller is a sibling of the target. - - ``others=True`` keeps the target and kills every other pane in its window, so - the danger is the caller pane being one of those siblings (not the target). - """ - caller = caller_of(engine) - if not caller.in_tmux or not caller.pane_id or caller.pane_id == target_pane: - return - if not socket_could_match(engine_socket(engine), caller): - return - caller_window = await caller_window_or_none(engine, caller.pane_id, version) - if caller_window is None: - return - target_window = await window_id(engine, PaneId(target_pane), version) - if caller_window == target_window: - raise_target_hint( - f"refusing to kill the other panes of window {target_window}: pane " - f"{caller.pane_id} runs this MCP server. Kill panes individually " - f"(excluding {caller.pane_id}), or run the tmux command manually.", - ) - - async def alist_panes( engine: AsyncTmuxEngine, target: str | Target | None = None, diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index 978b7c421..dc10b2fc5 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -5,17 +5,9 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced -from libtmux.experimental.mcp.vocabulary._caller import ( - engine_socket, - socket_could_match, -) from libtmux.experimental.mcp.vocabulary._resolve import ( - caller_of, - caller_session_or_none, - caller_window_or_none, + guard_kill_other_windows, guard_self_kill, - raise_target_hint, - session_id_of, window_id, ) from libtmux.experimental.mcp.vocabulary._results import Listing, WindowResult @@ -142,7 +134,7 @@ async def akill_window( resolved = resolve_target(target) target_window = await window_id(engine, resolved, version) if others: - await _guard_kill_other_windows(engine, target, target_window, version) + await guard_kill_other_windows(engine, target, target_window, version) else: await guard_self_kill(engine, window=target_window, version=version) ( @@ -154,36 +146,6 @@ async def akill_window( ).raise_for_status() -async def _guard_kill_other_windows( - engine: AsyncTmuxEngine, - target: str | Target, - target_window: str, - version: str | None, -) -> None: - """Refuse ``kill_window(others=True)`` when the caller is a same-session sibling. - - ``others=True`` keeps the target window and kills every other window in its - session, so the danger is the caller's window being one of those siblings. - """ - caller = caller_of(engine) - if not caller.in_tmux or not caller.pane_id: - return - if not socket_could_match(engine_socket(engine), caller): - return - caller_window = await caller_window_or_none(engine, caller.pane_id, version) - if caller_window is None or caller_window == target_window: - return # caller not on this server, or it is the kept target window - target_session = await session_id_of(engine, target, version) - caller_session = await caller_session_or_none(engine, caller.pane_id, version) - if caller_session is None or caller_session != target_session: - return - raise_target_hint( - f"refusing to kill the other windows of session {caller_session}: window " - f"{caller_window} holds this MCP server's pane {caller.pane_id}. Exclude " - "it, or run the tmux command manually.", - ) - - async def alist_windows( engine: AsyncTmuxEngine, target: str | Target | None = None, diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index c676e013b..802b9b77a 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -22,6 +22,7 @@ kill_window, list_panes, respawn_pane, + split_pane, ) from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine from libtmux.experimental.mcp.vocabulary._caller import ( @@ -382,3 +383,82 @@ async def main() -> None: with pytest.raises(ToolError, match="this MCP server"): asyncio.run(main()) + + +# --------------------------------------------------------------------------- # +# Deferrals: authoritative socket (1) + others=True per-op guard (2) +# --------------------------------------------------------------------------- # +def test_conservative_socket_prefers_explicit_path() -> None: + """An explicit -S path is authoritative as-is; no tmux query is issued.""" + from libtmux.experimental.mcp.vocabulary._resolve import conservative_socket + + class Pathed(SyncToAsyncEngine): + server_args = ("-S", "/tmp/explicit-socket") + + async def main() -> str | None: + return await conservative_socket(Pathed(ConcreteEngine()), None) + + assert asyncio.run(main()) == "/tmp/explicit-socket" + + +def test_kill_pane_others_refuses_caller_sibling_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """kill_pane(others=True) refuses when the caller is a sibling of the target.""" + engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(engine, name="killothers") + try: + pane_a = created.first_pane_id + assert pane_a is not None + pane_b = split_pane(engine, pane_a, horizontal=True).pane_id + monkeypatch.setenv("TMUX_PANE", pane_b) # caller is sibling B + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + with pytest.raises(ToolError, match="other panes"): + kill_pane(engine, pane_a, others=True) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(engine, created.session_id) + + +def test_op_kill_pane_others_is_guarded_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The per-op kill surface routes others=True to the sibling guard too.""" + from libtmux.experimental.engines import AsyncSubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + sync_engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(sync_engine, name="opkillothers") + try: + pane_a = created.first_pane_id + assert pane_a is not None + pane_b = split_pane(sync_engine, pane_a, horizontal=True).pane_id + monkeypatch.setenv("TMUX_PANE", pane_b) + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + server = build_async_server( + AsyncSubprocessEngine.for_server(session.server), + events="off", + expose_operations=True, + ) + + async def main() -> None: + async with fastmcp.Client(server) as client: + await client.call_tool( + "op_kill_pane", {"target": pane_a, "others": True} + ) + + with pytest.raises(ToolError, match="other panes"): + asyncio.run(main()) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(sync_engine, created.session_id) From 9e7354ac3aa3fa6c402e7b1369769f8c35539164 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 19:14:11 -0500 Subject: [PATCH 073/223] Mcp(fix): Harden wait_for_output monitor why: An adversarial review found the needle-free monitor could falsely report a command "settled" and could raise or mislead when the watched pane died -- the exact cases the design targets. what: - Make AsyncControlModeEngine.subscribe() a true broadcast: each subscriber gets its own queue, so wait_for_output, watch_events, and poll_events no longer steal each other's %output frames (which caused a premature settle with truncated text). - Make wait_for_output fail-safe when the watched pane is gone: _read_done no longer raises or fabricates pane_dead=False on a blank or fallback-pane probe (pane_dead becomes Optional/unknown, keyed on #{pane_id}), and the settle snapshot capture is guarded; the result is preserved. - Add a derived exit_code to MonitorResult. - Supervise the pull ring drainer (aclosing + recorded error surfaced via poll_events) so a reader failure cannot silently freeze it. - Regression test: two concurrent subscribers each see every event. --- .../engines/async_control_mode.py | 58 ++++++++--- src/libtmux/experimental/mcp/events.py | 95 ++++++++++++++----- tests/experimental/mcp/test_events.py | 32 ++++++- 3 files changed, 144 insertions(+), 41 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index c9939c52d..fbd0fd1a5 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -87,6 +87,26 @@ class _PendingCommand: blocks: list[ControlModeBlock] = field(default_factory=list) +def _offer( + queue: asyncio.Queue[ControlNotification], + notification: ControlNotification, +) -> int: + """Put *notification* on *queue*, dropping the oldest on overflow. + + Returns ``1`` when a notification was dropped, else ``0`` (so a broadcast can + tally drops without a ``try``/``except`` in its hot loop). + """ + try: + queue.put_nowait(notification) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(notification) + return 1 + return 0 + + class AsyncControlModeEngine: """Execute tmux commands over one persistent async ``tmux -C`` connection. @@ -120,9 +140,8 @@ def __init__( self.timeout = timeout self._parser = ControlModeParser() self._pending: collections.deque[_PendingCommand] = collections.deque() - self._events: asyncio.Queue[ControlNotification] = asyncio.Queue( - maxsize=event_queue_size, - ) + self._event_queue_size = event_queue_size + self._subscribers: set[asyncio.Queue[ControlNotification]] = set() self._dropped_notifications = 0 self._proc: asyncio.subprocess.Process | None = None self._reader_task: asyncio.Task[None] | None = None @@ -249,10 +268,21 @@ async def run_batch( async def subscribe(self) -> AsyncIterator[ControlNotification]: """Yield asynchronous tmux notifications as they arrive. - The iterator runs until the engine is closed or cancelled by the caller. + Each subscriber gets its own queue, so concurrent subscribers (the event + push tool, the pull ring, the output monitor) each see *every* + notification rather than competing for one shared stream. The iterator + runs until the engine is closed or the caller stops iterating; its queue + is unregistered on exit. """ - while True: - yield await self._events.get() + queue: asyncio.Queue[ControlNotification] = asyncio.Queue( + maxsize=self._event_queue_size, + ) + self._subscribers.add(queue) + try: + while True: + yield await queue.get() + finally: + self._subscribers.discard(queue) @property def dropped_notifications(self) -> int: @@ -339,16 +369,14 @@ def _dispatch_block(self, block: ControlModeBlock) -> None: pending.future.set_result(_merge_blocks(pending.blocks, pending.argv)) def _publish(self, line: bytes) -> None: - """Enqueue a notification, dropping the oldest on overflow.""" + """Broadcast a notification to every subscriber (drop-oldest per queue). + + Runs synchronously from the single reader task, so the subscriber set is + never mutated mid-iteration. + """ notification = ControlNotification.parse(line) - try: - self._events.put_nowait(notification) - except asyncio.QueueFull: - self._dropped_notifications += 1 - with contextlib.suppress(asyncio.QueueEmpty): - self._events.get_nowait() - with contextlib.suppress(asyncio.QueueFull): - self._events.put_nowait(notification) + for queue in self._subscribers: + self._dropped_notifications += _offer(queue, notification) def _mark_dead(self, error: BaseException) -> None: """Record the engine as dead and fail all pending commands.""" diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index ed8fa2c68..3f290e54f 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -37,6 +37,7 @@ accumulate_until_settle, output_payload, ) +from libtmux.experimental.ops import TmuxCommandError if t.TYPE_CHECKING: from collections.abc import AsyncGenerator, AsyncIterator, Sequence @@ -53,6 +54,7 @@ # tmux format read once at settle to fill DoneMetadata (tab-joined, one round-trip). _DONE_FORMAT = "\t".join( ( + "#{pane_id}", "#{pane_dead}", "#{pane_dead_status}", "#{pane_dead_signal}", @@ -71,9 +73,11 @@ class DoneMetadata: A ``pane_dead`` pane with a ``pane_dead_status`` is a *hard* "process exited" signal; ``pane_current_command`` reverting to a shell is a *soft* "command finished" signal. The screen-state fields add context without claiming intent. + ``pane_dead`` is ``None`` when the pane is gone or its liveness could not be + read (the command exited and took the pane with it). """ - pane_dead: bool + pane_dead: bool | None pane_dead_status: int | None pane_dead_signal: str | None pane_current_command: str | None @@ -92,6 +96,8 @@ class MonitorResult: ``truncated``), ``stream_end`` (the notification stream ended). ``idle_ms_observed`` is only meaningful when ``reason == "settled"``; ``snapshot_lines`` is ``None`` when the call passed ``snapshot=False``. + ``exit_code`` is the process exit code when the watched process is known to + have exited (``done.pane_dead`` is true), else ``None``. """ pane_id: str @@ -104,6 +110,7 @@ class MonitorResult: truncated: bool dropped: int done: DoneMetadata + exit_code: int | None snapshot_lines: tuple[str, ...] | None @@ -168,6 +175,7 @@ def __init__(self, engine: _StreamEngine, maxlen: int = _RING_SIZE) -> None: ) self._seq = 0 self._task: asyncio.Task[None] | None = None + self._error: str | None = None def _ensure_started(self) -> None: """Start the drainer task once, lazily, on the running loop.""" @@ -175,17 +183,29 @@ def _ensure_started(self) -> None: self._task = asyncio.create_task(self._drain(), name="libtmux-mcp-events") async def _drain(self) -> None: - """Copy every notification into the ring buffer.""" - stream: AsyncIterator[t.Any] = self._engine.subscribe() - async for notification in stream: - self._seq += 1 - self._buffer.append((self._seq, _event_dict(notification))) + """Copy every notification into the ring buffer (supervised, fail-safe). + + A reader failure is recorded and surfaced on the next :meth:`since` call, + rather than silently freezing the cursor or raising at garbage-collection + time. The subscription is closed deterministically via ``aclosing``. + """ + stream = t.cast("AsyncGenerator[t.Any, None]", self._engine.subscribe()) + try: + async with contextlib.aclosing(stream) as managed: + async for notification in managed: + self._seq += 1 + self._buffer.append((self._seq, _event_dict(notification))) + except Exception as error: # capture: the drainer must never crash + self._error = repr(error) def since(self, seq: int) -> dict[str, t.Any]: """Return buffered events with sequence number greater than *seq*.""" self._ensure_started() events = [event for n, event in self._buffer if n > seq] - return {"events": events, "cursor": self._seq} + out: dict[str, t.Any] = {"events": events, "cursor": self._seq} + if self._error is not None: + out["error"] = self._error + return out def register_events( @@ -297,12 +317,36 @@ async def poll_events(since: int = 0) -> dict[str, t.Any]: mcp.add_tool(tool) +def _gone_done() -> DoneMetadata: + """``DoneMetadata`` for a pane that is gone or whose liveness can't be read.""" + return DoneMetadata( + pane_dead=None, + pane_dead_status=None, + pane_dead_signal=None, + pane_current_command=None, + cursor_y=None, + history_size=None, + pane_in_mode=False, + ) + + async def _read_done(engine: _StreamEngine, pane_id: str) -> DoneMetadata: - """Fill :class:`DoneMetadata` for *pane_id* in one ``display-message`` read.""" + """Fill :class:`DoneMetadata` for *pane_id* in one ``display-message`` read. + + Fail-safe: when the pane is gone -- the probe errors, returns blank, or tmux + resolves a *different* (fallback) pane -- liveness is reported as unknown + (``pane_dead=None``) rather than raising or fabricating ``pane_dead=False``, + so a command that exited and took its pane still yields a result. + """ from libtmux.experimental.mcp.vocabulary.server import adisplay_message - text = (await adisplay_message(engine, pane_id, _DONE_FORMAT)).text - fields = (text.split("\t") + [""] * 7)[:7] + try: + text = (await adisplay_message(engine, pane_id, _DONE_FORMAT)).text + except TmuxCommandError: + return _gone_done() + fields = (text.split("\t") + [""] * 8)[:8] + if not fields[0].strip() or fields[0].strip() != pane_id: + return _gone_done() # blank probe, or tmux resolved a fallback pane def _as_int(value: str) -> int | None: value = value.strip() @@ -317,13 +361,13 @@ def _as_str(value: str) -> str | None: return value.strip() or None return DoneMetadata( - pane_dead=fields[0].strip() == "1", - pane_dead_status=_as_int(fields[1]), - pane_dead_signal=_as_str(fields[2]), - pane_current_command=_as_str(fields[3]), - cursor_y=_as_int(fields[4]), - history_size=_as_int(fields[5]), - pane_in_mode=fields[6].strip() == "1", + pane_dead=fields[1].strip() == "1", + pane_dead_status=_as_int(fields[2]), + pane_dead_signal=_as_str(fields[3]), + pane_current_command=_as_str(fields[4]), + cursor_y=_as_int(fields[5]), + history_size=_as_int(fields[6]), + pane_in_mode=fields[7].strip() == "1", ) @@ -451,13 +495,15 @@ async def _frames() -> AsyncGenerator[str, None]: done = await _read_done(engine, pane) snapshot_lines: tuple[str, ...] | None = None if snapshot: - captured = await acapture_pane( - engine, - pane, - join_wrapped=True, - trim_trailing=True, - ) - snapshot_lines = tuple(captured.lines) + # A pane that died at settle cannot be captured -- keep the result. + with contextlib.suppress(TmuxCommandError): + captured = await acapture_pane( + engine, + pane, + join_wrapped=True, + trim_trailing=True, + ) + snapshot_lines = tuple(captured.lines) return MonitorResult( pane_id=pane, @@ -470,6 +516,7 @@ async def _frames() -> AsyncGenerator[str, None]: truncated=outcome.truncated, dropped=dropped, done=done, + exit_code=done.pane_dead_status if done.pane_dead else None, snapshot_lines=snapshot_lines, ) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index bfc69b313..079fbe9c9 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -227,7 +227,8 @@ async def main() -> t.Any: assert data.frame_count == 2 assert data.truncated is False assert data.snapshot_lines == [] - assert data.done.pane_dead is False + # The fake's pane id is unverifiable post-settle, so liveness reads unknown. + assert data.done.pane_dead is None def test_monitor_snapshot_false_omits_grid() -> None: @@ -334,7 +335,7 @@ def test_read_done_parses_display_message_fields() -> None: """_read_done maps the tab-joined display-message into DoneMetadata.""" from libtmux.experimental.mcp.events import _read_done - done_line = "1\t137\tHUP\tbash\t3\t50\t1" + done_line = "%1\t1\t137\tHUP\tbash\t3\t50\t1" # pane_id first engine = InstrumentedEngine(done_line=done_line) done = asyncio.run(_read_done(engine, "%1")) assert done.pane_dead is True @@ -346,6 +347,33 @@ def test_read_done_parses_display_message_fields() -> None: assert done.pane_in_mode is True +def test_subscribe_broadcasts_to_every_consumer() -> None: + """Concurrent subscribers each receive every notification (no frame stealing). + + A regression for the competing-consumer bug: a single shared queue handed each + item to exactly one waiter, so wait_for_output and watch_events/poll_events + stole each other's %output and the monitor could falsely report 'settled'. + """ + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + engine = AsyncControlModeEngine() + + async def main() -> tuple[str, str]: + stream_a, stream_b = engine.subscribe(), engine.subscribe() + first = asyncio.ensure_future(stream_a.__anext__()) + second = asyncio.ensure_future(stream_b.__anext__()) + await asyncio.sleep(0) # let both register their queues at the first await + await asyncio.sleep(0) + engine._publish(b"%window-add @3") + notif_a, notif_b = await first, await second + # asyncio.run finalizes the suspended generators via shutdown_asyncgens. + return notif_a.raw, notif_b.raw + + raw_a, raw_b = asyncio.run(main()) + assert raw_a == "%window-add @3" + assert raw_b == "%window-add @3" # both, not split across consumers + + def test_instructions_surface_wait_for_output() -> None: """The server instructions name the run-a-command-and-wait workflow. From 4253ca4eeb9a5b3985e9006b34297911bfd50ef8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:04:55 -0500 Subject: [PATCH 074/223] Workspace(feat): Thread env/shell/options through declarative tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxp parity — per-pane/window environment, shell, global options, and options_after were silently dropped by the declarative tier even though the Core ops already accept every one of them. what: - IR: Pane.environment/shell; Window.environment/window_shell/ options_after; Workspace.global_options - analyzer: read the new tmuxp keys (lenient, empty-default) - compiler: thread env/shell into split-window/new-window; fold window-0 + its first pane's env into new-session -e so the reused implicit pane keeps its env with no extra dispatch; global_options -> set-option -g; options_after emitted after select-layout/focus - reconcile the stale Pane.focus docstring (first-pane focus is valid now that the creator captures the first pane's id) - tests: global_options, first-pane-env fold, window/pane shell precedence, options_after ordering --- .../experimental/workspace/analyzer.py | 6 ++ .../experimental/workspace/compiler.py | 26 ++++++ src/libtmux/experimental/workspace/ir.py | 39 +++++++- ..._async_control_engine_workspace_builder.py | 92 +++++++++++++++++++ 4 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 55118204f..8e7a07a73 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -44,6 +44,7 @@ def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: start_directory=data.get("start_directory"), environment=dict(data.get("environment", {}) or {}), options=dict(data.get("options", {}) or {}), + global_options=dict(data.get("global_options", {}) or {}), windows=windows, before_script=data.get("before_script"), on_exists=data.get("on_exists", "error"), @@ -83,6 +84,9 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: start_directory=raw.get("start_directory"), focus=bool(raw.get("focus", False)), options=dict(raw.get("options", {}) or {}), + options_after=dict(raw.get("options_after", {}) or {}), + environment=dict(raw.get("environment", {}) or {}), + window_shell=raw.get("window_shell"), panes=[_pane(p) for p in raw.get("panes", []) or []], ) @@ -100,6 +104,8 @@ def _pane(raw: t.Any) -> Pane: start_directory=raw.get("start_directory"), sleep_before=raw.get("sleep_before"), sleep_after=raw.get("sleep_after"), + environment=dict(raw.get("environment", {}) or {}), + shell=raw.get("shell"), ) msg = f"unsupported pane config: {raw!r}" raise TypeError(msg) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 8dd9e3efc..610aa65bc 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -142,6 +142,10 @@ def _emit_window( or window.start_directory or ws.start_directory ), + environment=( + dict(pane.environment) or dict(window.environment) or None + ), + shell=pane.shell or window.window_shell, ), ) if pane.commands: @@ -173,6 +177,23 @@ def _emit_window( plan.add(SelectLayout(target=window_ref, layout=window.layout)) for target in focus_targets: plan.add(SelectPane(target=target)) + for key, value in window.options_after.items(): + plan.add(SetWindowOption(target=window_ref, option=key, value=value)) + + +def _creator_environment(window: Window) -> dict[str, str]: + """Env for a window's *first* (implicit) pane, applied via its creator's ``-e``. + + The first pane reuses the window's implicit pane rather than splitting, so its + process environment cannot ride a ``split-window -e``. Instead the window's + ``environment`` (and its first pane's own ``environment``) fold into the + creator -- ``new-session -e`` for window 0, ``new-window -e`` for the rest -- + applying it without an extra dispatch. + """ + env = dict(window.environment) + if window.panes: + env.update(window.panes[0].environment) + return env def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: @@ -195,6 +216,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: start_directory=ws.start_directory, width=width, height=height, + environment=_creator_environment(ws.windows[0]) or None, capture_panes=True, ), ) @@ -202,6 +224,8 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: plan.add(SetEnvironment(target=session, name=key, value=value)) for key, value in ws.options.items(): plan.add(SetOption(target=session, option=key, value=value)) + for key, value in ws.global_options.items(): + plan.add(SetOption(option=key, value=value, global_=True)) window_refs: list[SlotRef] = [] for index, window in enumerate(ws.windows): @@ -217,6 +241,8 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: target=session, name=window.name, start_directory=window.start_directory or ws.start_directory, + environment=_creator_environment(window) or None, + window_shell=window.window_shell, capture_pane=True, ), ) diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 85e34edca..ff2ab1655 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -49,15 +49,24 @@ class Pane: Command(s) to send after the pane is created (a bare string is one command). focus : bool - Select this pane once its window's panes are built. Focusing the *first* - pane of a multi-pane window is rejected at compile time (the implicit - first pane has no captured id after the window is split). + Select this pane once its window's panes are built. Every pane -- the + first one included -- has a concrete captured id (the window's creator + captures its first pane), so focusing any pane is valid. start_directory : str or None Working directory (inherited from window/session when unset). suppress_history : bool Keep sent commands out of shell history (leading-space trick). sleep_before, sleep_after : float or None Host-side delays around this pane's commands (orchestration, not tmux). + environment : Mapping[str, str] + Process environment for a *split* pane (``split-window -e``). For the + window's first pane -- which reuses the window's implicit pane rather than + splitting -- this env is folded into the window's creator instead, so it + applies without an extra dispatch. + shell : str or None + A shell command to launch in the pane instead of the default shell + (``split-window`` trailing command); falls back to the window's + ``window_shell``. """ run: str | Sequence[str] | None = None @@ -66,6 +75,8 @@ class Pane: suppress_history: bool = True sleep_before: float | None = None sleep_after: float | None = None + environment: Mapping[str, str] = field(default_factory=dict) + shell: str | None = None @property def commands(self) -> tuple[str, ...]: @@ -92,7 +103,19 @@ class Window: focus : bool Select this window at the end of the build. options : Mapping[str, str] - ``set-window-option`` key/values. + ``set-window-option`` key/values applied *before* the panes are built. + options_after : Mapping[str, str] + ``set-window-option`` key/values applied *after* the layout and pane + focus -- for options (e.g. ``main-pane-width``) that only take effect + once the panes and layout exist. + environment : Mapping[str, str] + Process environment for the window (``new-window -e``), inherited by its + panes. For window 0 (which reuses the session's implicit window) this is + folded into ``new-session -e`` instead. + window_shell : str or None + A shell command to launch in the window's first pane instead of the + default shell (``new-window`` trailing command); also the fallback + ``shell`` for the window's split panes. panes : Sequence[Pane] The window's panes (the first reuses the window's implicit pane). """ @@ -102,6 +125,9 @@ class Window: start_directory: str | None = None focus: bool = False options: Mapping[str, str] = field(default_factory=dict) + options_after: Mapping[str, str] = field(default_factory=dict) + environment: Mapping[str, str] = field(default_factory=dict) + window_shell: str | None = None panes: Sequence[Pane] = () @@ -118,9 +144,11 @@ class Workspace: start_directory : str or None Working directory for the session. environment : Mapping[str, str] - ``set-environment`` key/values. + ``set-environment`` key/values (session-scoped tmux environment). options : Mapping[str, str] ``set-option`` (session) key/values. + global_options : Mapping[str, str] + ``set-option -g`` key/values (server-global options). windows : Sequence[Window] The session's windows. before_script : str or None @@ -134,6 +162,7 @@ class Workspace: start_directory: str | None = None environment: Mapping[str, str] = field(default_factory=dict) options: Mapping[str, str] = field(default_factory=dict) + global_options: Mapping[str, str] = field(default_factory=dict) windows: Sequence[Window] = () before_script: str | None = None on_exists: t.Literal["error", "replace", "reuse"] = "error" diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 826bb4033..f4d8c9cd6 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -28,10 +28,13 @@ LazyPlan, MarkedPlanner, NewSession, + NewWindow, + SelectLayout, SequentialPlanner, SetEnvironment, SetOption, SetWindowOption, + SplitWindow, ) from libtmux.experimental.workspace import ( HostStep, @@ -452,6 +455,95 @@ def test_compile_emits_environment_and_options() -> None: assert (set_wopt.option, set_wopt.value) == ("main-pane-height", "10") +def test_compile_emits_global_options() -> None: + """Workspace.global_options compile to ``set-option -g`` (no target).""" + ws = Workspace( + name="ws-global", + global_options={"status-position": "top"}, + windows=[Window("w", panes=[Pane(run="echo a")])], + ) + ops = compile_full(ws).plan.operations + global_opt = next(op for op in ops if isinstance(op, SetOption) and op.global_) + assert (global_opt.option, global_opt.value) == ("status-position", "top") + assert global_opt.target is None # -g options carry no target + + +def test_compile_folds_first_pane_env_into_creator() -> None: + """Window/first-pane env rides the creator's ``-e`` (no extra dispatch). + + Window 0 reuses the session's implicit pane, so its env -- and its first + pane's -- folds into ``new-session -e``; window 2..N fold into ``new-window + -e``. A *split* pane carries its own ``-e``. + """ + ws = Workspace( + name="ws-env", + windows=[ + Window( + "editor", + environment={"WIN_ENV": "w"}, + panes=[ + Pane(run="vim", environment={"PANE_ENV": "p"}), + Pane(run="htop", environment={"SPLIT_ENV": "s"}), + ], + ), + Window("logs", environment={"W2": "x"}, panes=[Pane(run="tail")]), + ], + ) + ops = compile_full(ws).plan.operations + new_session = next(op for op in ops if isinstance(op, NewSession)) + new_window = next(op for op in ops if isinstance(op, NewWindow)) + split = next(op for op in ops if isinstance(op, SplitWindow)) + # window 0 + its first pane fold into new-session -e + assert new_session.environment == {"WIN_ENV": "w", "PANE_ENV": "p"} + # window 1 folds into new-window -e + assert new_window.environment == {"W2": "x"} + # a split pane carries its own env (falling back to the window's) + assert split.environment == {"SPLIT_ENV": "s"} + + +def test_compile_threads_window_and_pane_shell() -> None: + """window_shell rides new-window; pane.shell (then window_shell) rides split.""" + ws = Workspace( + name="ws-shell", + windows=[ + Window("a", panes=[Pane(run="x")]), + Window( + "b", + window_shell="fish", + panes=[Pane(run="x"), Pane(run="y", shell="zsh")], + ), + ], + ) + ops = compile_full(ws).plan.operations + new_window = next(op for op in ops if isinstance(op, NewWindow)) + split = next(op for op in ops if isinstance(op, SplitWindow)) + assert new_window.window_shell == "fish" + assert split.shell == "zsh" # pane.shell wins over window_shell + + +def test_compile_emits_options_after_following_layout() -> None: + """options_after compile to set-window-option *after* the layout op.""" + ws = Workspace( + name="ws-after", + windows=[ + Window( + "w", + layout="main-vertical", + options_after={"main-pane-width": "120"}, + panes=[Pane(run="a"), Pane(run="b")], + ), + ], + ) + ops = compile_full(ws).plan.operations + layout_at = next(i for i, op in enumerate(ops) if isinstance(op, SelectLayout)) + after_at = next( + i + for i, op in enumerate(ops) + if isinstance(op, SetWindowOption) and op.option == "main-pane-width" + ) + assert after_at > layout_at # options_after runs once the layout exists + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From 12cd0f29cbaba1f6bb5bd03f934684ac6b28bf28 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:07:30 -0500 Subject: [PATCH 075/223] Workspace(feat): Add per-command Command (enter + sleeps) why: tmuxp lets a shell_command item set enter=False (type without submitting) and per-command sleep_before/sleep_after; the flat string model dropped that fidelity. what: - IR: Command(cmd, enter=True, sleep_before, sleep_after); widen Pane.run to str | Command | Sequence[str | Command]; Pane.commands now normalizes to tuple[Command, ...] (bare str -> Command) - analyzer: preserve per-item enter/sleep from {cmd, ...} maps, else keep a plain string - compiler: emit SendKeys(enter=cmd.enter) + per-command sleep host steps, nested inside the pane-level sleeps - export Command from the workspace package - tests: per-command enter/sleep compile + run-form normalization --- .../experimental/workspace/__init__.py | 3 +- .../experimental/workspace/analyzer.py | 37 +++++++++--- .../experimental/workspace/compiler.py | 20 +++++-- src/libtmux/experimental/workspace/ir.py | 56 ++++++++++++++++--- ..._async_control_engine_workspace_builder.py | 55 +++++++++++++++--- 5 files changed, 141 insertions(+), 30 deletions(-) diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 9b1f34bfe..07796f8b4 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -31,10 +31,11 @@ compile_workspace, ) from libtmux.experimental.workspace.confirm import ConfirmReport, confirm -from libtmux.experimental.workspace.ir import Pane, Window, Workspace +from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace __all__ = ( + "Command", "Compiled", "ConfirmReport", "HostStep", diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 8e7a07a73..24e19de7a 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -20,10 +20,10 @@ 'dev' >>> [w.name for w in ws.windows] ['editor', 'logs'] ->>> ws.windows[0].panes[0].commands -('vim',) ->>> ws.windows[0].panes[1].commands -('cd src', 'pytest -q') +>>> [c.cmd for c in ws.windows[0].panes[0].commands] +['vim'] +>>> [c.cmd for c in ws.windows[0].panes[1].commands] +['cd src', 'pytest -q'] """ from __future__ import annotations @@ -31,7 +31,7 @@ import collections.abc import typing as t -from libtmux.experimental.workspace.ir import Pane, Window, Workspace +from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: @@ -111,16 +111,35 @@ def _pane(raw: t.Any) -> Pane: raise TypeError(msg) -def _shell_commands(value: t.Any) -> tuple[str, ...]: - """Normalize a ``shell_command`` (None / string / list of str|{cmd}).""" +def _shell_commands(value: t.Any) -> tuple[str | Command, ...]: + """Normalize a ``shell_command`` (None / string / list of str|{cmd}). + + A ``{cmd}`` mapping that carries ``enter``/``sleep_before``/``sleep_after`` + becomes a :class:`~.ir.Command` preserving that orchestration; a plain + command stays a bare string. + """ if value is None: return () if isinstance(value, str): return (value,) - out: list[str] = [] + out: list[str | Command] = [] for item in t.cast("collections.abc.Sequence[t.Any]", value): if isinstance(item, str): out.append(item) elif isinstance(item, collections.abc.Mapping): - out.append(str(item["cmd"])) + out.append(_command(item)) return tuple(out) + + +def _command(item: collections.abc.Mapping[str, t.Any]) -> str | Command: + """Build a Command from a ``{cmd, enter?, sleep_before?, sleep_after?}`` map. + + Stays a bare string when no per-command overrides are present. + """ + cmd = str(item["cmd"]) + enter = bool(item.get("enter", True)) + sleep_before = item.get("sleep_before") + sleep_after = item.get("sleep_after") + if enter and sleep_before is None and sleep_after is None: + return cmd + return Command(cmd, enter=enter, sleep_before=sleep_before, sleep_after=sleep_after) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 610aa65bc..2ddcd6861 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -148,7 +148,8 @@ def _emit_window( shell=pane.shell or window.window_shell, ), ) - if pane.commands: + commands = pane.commands + if commands: if pane.sleep_before is not None: _schedule_before( host_after, @@ -156,15 +157,26 @@ def _emit_window( len(plan), HostStep("sleep", seconds=pane.sleep_before), ) - for command in pane.commands: + for command in commands: + if command.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=command.sleep_before), + ) plan.add( SendKeys( target=target, - keys=command, - enter=True, + keys=command.cmd, + enter=command.enter, suppress_history=pane.suppress_history, ), ) + if command.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=command.sleep_after), + ) if pane.sleep_after is not None: host_after.setdefault(len(plan) - 1, []).append( HostStep("sleep", seconds=pane.sleep_after), diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index ff2ab1655..9984908b6 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -39,15 +39,44 @@ from libtmux.experimental.ops.plan import LazyPlan, PlanResult +@dataclass(frozen=True) +class Command: + """One command sent to a pane, with per-command orchestration. + + Parameters + ---------- + cmd : str + The command line sent via ``send-keys``. + enter : bool + Submit the command with ``Enter`` (``False`` types it without running -- + e.g. to pre-fill a prompt). + sleep_before, sleep_after : float or None + Host-side delays around this individual command (orchestration, not tmux). + + Examples + -------- + >>> Command("pytest -q").enter + True + >>> Command("git commit", enter=False).cmd + 'git commit' + """ + + cmd: str + enter: bool = True + sleep_before: float | None = None + sleep_after: float | None = None + + @dataclass(frozen=True) class Pane: """A pane in the declared workspace. Parameters ---------- - run : str or Sequence[str] or None - Command(s) to send after the pane is created (a bare string is one - command). + run : str or Command or Sequence[str or Command] or None + Command(s) to send after the pane is created. A bare string is one + command (submitted with Enter); a :class:`Command` carries per-command + ``enter`` and sleep overrides. focus : bool Select this pane once its window's panes are built. Every pane -- the first one included -- has a concrete captured id (the window's creator @@ -69,7 +98,7 @@ class Pane: ``window_shell``. """ - run: str | Sequence[str] | None = None + run: str | Command | Sequence[str | Command] | None = None focus: bool = False start_directory: str | None = None suppress_history: bool = True @@ -79,13 +108,22 @@ class Pane: shell: str | None = None @property - def commands(self) -> tuple[str, ...]: - """The pane's commands as a tuple (a bare string becomes one command).""" + def commands(self) -> tuple[Command, ...]: + """The pane's commands as :class:`Command` values (a bare string -> Command). + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Pane, Command + >>> Pane(run="vim").commands + (Command(cmd='vim', enter=True, sleep_before=None, sleep_after=None),) + >>> [c.cmd for c in Pane(run=["a", Command("b", enter=False)]).commands] + ['a', 'b'] + """ if self.run is None: return () - if isinstance(self.run, str): - return (self.run,) - return tuple(self.run) + items: Sequence[str | Command] + items = (self.run,) if isinstance(self.run, (str, Command)) else self.run + return tuple(c if isinstance(c, Command) else Command(c) for c in items) @dataclass(frozen=True) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index f4d8c9cd6..864c1fe73 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -30,6 +30,7 @@ NewSession, NewWindow, SelectLayout, + SendKeys, SequentialPlanner, SetEnvironment, SetOption, @@ -37,6 +38,7 @@ SplitWindow, ) from libtmux.experimental.workspace import ( + Command, HostStep, Pane, Window, @@ -102,8 +104,11 @@ def test_workspace_analyze_normalizes_shorthand() -> None: ws = analyze(_YAML) assert ws.name == "ws-offline" assert [w.name for w in ws.windows] == ["editor", "logs"] - assert ws.windows[0].panes[0].commands == ("echo top",) - assert ws.windows[0].panes[1].commands == ("echo bottom-1", "echo bottom-2") + assert [c.cmd for c in ws.windows[0].panes[0].commands] == ["echo top"] + assert [c.cmd for c in ws.windows[0].panes[1].commands] == [ + "echo bottom-1", + "echo bottom-2", + ] assert ws.windows[0].panes[1].focus is True @@ -324,10 +329,46 @@ async def main() -> PlanResult: def test_pane_commands_normalizes_run_forms() -> None: - """Pane.commands turns run (None / str / sequence) into a command tuple.""" + """Pane.commands turns run (None / str / Command / sequence) into Commands.""" assert Pane().commands == () - assert Pane(run="vim").commands == ("vim",) - assert Pane(run=["cd src", "pytest -q"]).commands == ("cd src", "pytest -q") + assert [c.cmd for c in Pane(run="vim").commands] == ["vim"] + assert [c.cmd for c in Pane(run=["cd src", "pytest -q"]).commands] == [ + "cd src", + "pytest -q", + ] + # bare strings default to enter=True; a Command carries its own overrides + mixed = Pane(run=["plain", Command("typed", enter=False, sleep_after=0.2)]).commands + assert (mixed[0].cmd, mixed[0].enter) == ("plain", True) + assert (mixed[1].cmd, mixed[1].enter, mixed[1].sleep_after) == ("typed", False, 0.2) + + +def test_compile_per_command_enter_and_sleeps() -> None: + """Command(enter=False) emits send-keys without Enter; per-cmd sleeps host-step.""" + ws = Workspace( + name="ws-cmd", + windows=[ + Window( + "w", + panes=[ + Pane( + run=[ + Command("git add -p", enter=False), + Command("echo done", sleep_before=0.3, sleep_after=0.4), + ], + ), + ], + ), + ], + ) + compiled = compile_full(ws) + sends = [op for op in compiled.plan.operations if isinstance(op, SendKeys)] + assert (sends[0].keys, sends[0].enter) == ("git add -p", False) + assert (sends[1].keys, sends[1].enter) == ("echo done", True) + send_at = [ + i for i, op in enumerate(compiled.plan.operations) if isinstance(op, SendKeys) + ] + assert HostStep("sleep", seconds=0.3) in compiled.host_after[send_at[1] - 1] + assert HostStep("sleep", seconds=0.4) in compiled.host_after[send_at[1]] def test_analyze_dimensions_list_and_mapping() -> None: @@ -360,8 +401,8 @@ def test_analyze_shell_command_shorthand_forms() -> None: }, ) panes = ws.windows[0].panes - assert panes[0].commands == ("echo solo",) - assert panes[1].commands == ("echo a", "echo b") + assert [c.cmd for c in panes[0].commands] == ["echo solo"] + assert [c.cmd for c in panes[1].commands] == ["echo a", "echo b"] assert panes[2].commands == () # a None pane is an empty (implicit) pane From 2a2ab0e5f8e179d1db4647787efe960995e1efd2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:11:02 -0500 Subject: [PATCH 076/223] Ops(feat): Add ForwardCaptureError + ShowOptionsResult.get_int MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: a plan step targeting a slot that captured no id failed with a generic OperationError pointing at the consumer; and integer options (base-index, pane-base-index) had to be hand-parsed from strings. what: - exc: ForwardCaptureError(OperationError) names the unbound slot/part and explains only a capturing creator can be targeted; _resolve_slot raises it (still an OperationError, so broad handlers keep working) - results: ShowOptionsResult.get_int(name, default) -> int - export ForwardCaptureError from the ops package - tests: assert ForwardCaptureError.slot + OperationError subclassing; get_int doctested note: PlanResult live-object accessors (pane/window/session) are deferred — speculative DX better suited to the future object-graph tier, not a tmuxp-parity requirement. --- src/libtmux/experimental/ops/__init__.py | 2 ++ src/libtmux/experimental/ops/exc.py | 31 ++++++++++++++++++++++++ src/libtmux/experimental/ops/plan.py | 8 ++---- src/libtmux/experimental/ops/results.py | 22 +++++++++++++++++ tests/experimental/ops/test_plan.py | 9 ++++--- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index e0d1a3e37..7950c3edd 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -98,6 +98,7 @@ from libtmux.experimental.ops.catalog import CatalogEntry, catalog from libtmux.experimental.ops.exc import ( DuplicateOperation, + ForwardCaptureError, OperationError, TmuxCommandError, UnknownOperation, @@ -160,6 +161,7 @@ "DuplicateOperation", "Effects", "FoldingPlanner", + "ForwardCaptureError", "HasSession", "HasSessionResult", "IndexRef", diff --git a/src/libtmux/experimental/ops/exc.py b/src/libtmux/experimental/ops/exc.py index e9a87ee2e..b780ff5ce 100644 --- a/src/libtmux/experimental/ops/exc.py +++ b/src/libtmux/experimental/ops/exc.py @@ -55,6 +55,37 @@ def __init__(self, kind: str) -> None: super().__init__(msg) +class ForwardCaptureError(OperationError): + """A plan step references a slot that captured no id. + + Raised when an operation targets the result of an earlier step (via a + :class:`~._types.SlotRef`) but that step captured no id -- either it has not + run yet (a forward or self reference), or it is not a capturing creator (a + mutating op, or a creator built with ``capture=False``). Failing here points + at the unbound reference rather than letting a later tmux command fail with an + opaque target error. + + Examples + -------- + >>> raise ForwardCaptureError(2, "self") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.ForwardCaptureError: plan step references slot 2 + (its own id) but that step captured no id; only an earlier capturing creator + can be targeted + """ + + def __init__(self, slot: int, part: str) -> None: + self.slot = slot + self.part = part + target = "its own id" if part == "self" else f"its {part!r} child" + msg = ( + f"plan step references slot {slot} ({target}) but that step captured " + f"no id; only an earlier capturing creator can be targeted" + ) + super().__init__(msg) + + class VersionUnsupported(OperationError): """An operation cannot render against the given tmux version. diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index aad70b331..c97d17598 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -32,7 +32,7 @@ Special, WindowId, ) -from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.exc import ForwardCaptureError from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.planner import Planner, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict @@ -89,11 +89,7 @@ def _resolve_slot( try: concrete = bindings[key] + ref.suffix except KeyError as error: - msg = ( - f"slot {ref.slot} (part {ref.part!r}) has no captured id yet; a plan " - f"step can only reference an earlier step that creates that object" - ) - raise OperationError(msg) from error + raise ForwardCaptureError(ref.slot, ref.part) from error return _target_from_id(concrete) diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index ae34b9463..ebd41b19e 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -345,6 +345,28 @@ class ShowOptionsResult(Result): options: Mapping[str, str] = field(default_factory=dict) + def get_int(self, name: str, default: int) -> int: + """Return option *name* parsed as an int, or *default* when absent. + + A small typed accessor for the integer options a builder reads back + (``base-index`` / ``pane-base-index`` / ``main-pane-width``), so callers + do not hand-parse :attr:`options` strings. + + Examples + -------- + >>> from libtmux.experimental.ops import ShowOptions + >>> result = ShowOptions().build_result( + ... returncode=0, + ... stdout=("base-index 1", "history-limit 5000"), + ... ) + >>> result.get_int("base-index", 0) + 1 + >>> result.get_int("status-interval", 15) + 15 + """ + value = self.options.get(name) + return int(value) if value is not None else default + @dataclass(frozen=True) class ShowBufferResult(Result): diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 812d20665..22caaf3a8 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -19,7 +19,7 @@ SwapPane, ) from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId -from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.exc import ForwardCaptureError, OperationError if t.TYPE_CHECKING: from libtmux.experimental.ops.operation import Operation @@ -134,9 +134,12 @@ def test_plan_serialization_round_trip() -> None: def test_plan_unresolvable_ref_fails_closed() -> None: - """Targeting a step that creates nothing raises a clear error.""" + """Targeting a step that creates nothing raises a clear ForwardCaptureError.""" plan = LazyPlan() typed = plan.add(SendKeys(target=PaneId("%1"), keys="x")) # creates no id plan.add(SendKeys(target=typed, keys="y")) - with pytest.raises(OperationError, match="no captured id"): + with pytest.raises(ForwardCaptureError, match="captured no id") as exc_info: plan.execute(ConcreteEngine()) + assert exc_info.value.slot == 0 # points at the non-capturing creator + # ForwardCaptureError stays an OperationError, so broad handlers keep working + assert isinstance(exc_info.value, OperationError) From eb16850bb428b080b2c23ad9b9341b70dbe1703a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:12:55 -0500 Subject: [PATCH 077/223] Workspace(feat): Add Workspace.to_dict + read suppress_history why: analyze (dict/YAML -> spec) had no inverse, so a spec could not round-trip back to a canonical tmuxp config; and the analyzer silently dropped pane suppress_history. what: - ir: Command.to_config + Pane/Window/Workspace.to_dict emit canonical (non-shorthand) tmuxp keys, omitting defaults; bare commands collapse back to strings - analyzer: read pane suppress_history (so to_dict round-trips it) - reconcile the package docstring: "WorkspaceBuilder" -> declarative workspace, note to_dict is analyze's inverse - test: analyze(ws.to_dict()) yields an identical compiled plan over a rich spec (env/options/global_options/options_after/Command/shell) --- .../experimental/workspace/__init__.py | 10 +- .../experimental/workspace/analyzer.py | 1 + src/libtmux/experimental/workspace/ir.py | 96 +++++++++++++++++++ ..._async_control_engine_workspace_builder.py | 28 ++++++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 07796f8b4..2b4c176a4 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -1,11 +1,11 @@ -"""Declarative WorkspaceBuilder: a structural object language over the Core ops. +"""Declarative workspace: a structural object language over the Core ops. The *Declarative* tier (à la SQLAlchemy Declarative on Core). Declare a workspace shape with :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane`; -:func:`~.analyzer.analyze` builds that tree from a tmuxp-style YAML/dict; the -compiler lowers it to a Core :class:`~libtmux.experimental.ops.plan.LazyPlan`; the -runner executes it over any engine, sync or async; :func:`~.confirm.confirm` -verifies the live result. +:func:`~.analyzer.analyze` builds that tree from a tmuxp-style YAML/dict (and +:meth:`~.ir.Workspace.to_dict` is its inverse); the compiler lowers it to a Core +:class:`~libtmux.experimental.ops.plan.LazyPlan`; the runner executes it over any +engine, sync or async; :func:`~.confirm.confirm` verifies the live result. Everything here is experimental and outside the versioning policy. diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 24e19de7a..574793194 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -102,6 +102,7 @@ def _pane(raw: t.Any) -> Pane: run=_shell_commands(raw.get("shell_command")), focus=bool(raw.get("focus", False)), start_directory=raw.get("start_directory"), + suppress_history=bool(raw.get("suppress_history", True)), sleep_before=raw.get("sleep_before"), sleep_after=raw.get("sleep_after"), environment=dict(raw.get("environment", {}) or {}), diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 9984908b6..0f942ca48 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -66,6 +66,23 @@ class Command: sleep_before: float | None = None sleep_after: float | None = None + def to_config(self) -> str | dict[str, t.Any]: + """Serialize to a tmuxp ``shell_command`` item. + + A plain command (``enter=True``, no sleeps) collapses back to a bare + string; otherwise a ``{cmd, ...}`` mapping carrying only the overrides. + """ + if self.enter and self.sleep_before is None and self.sleep_after is None: + return self.cmd + out: dict[str, t.Any] = {"cmd": self.cmd} + if not self.enter: + out["enter"] = False + if self.sleep_before is not None: + out["sleep_before"] = self.sleep_before + if self.sleep_after is not None: + out["sleep_after"] = self.sleep_after + return out + @dataclass(frozen=True) class Pane: @@ -125,6 +142,27 @@ def commands(self) -> tuple[Command, ...]: items = (self.run,) if isinstance(self.run, (str, Command)) else self.run return tuple(c if isinstance(c, Command) else Command(c) for c in items) + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical tmuxp pane config (inverse of the analyzer).""" + out: dict[str, t.Any] = {} + if self.run is not None: + out["shell_command"] = [c.to_config() for c in self.commands] + if self.focus: + out["focus"] = True + if self.start_directory is not None: + out["start_directory"] = self.start_directory + if not self.suppress_history: + out["suppress_history"] = False + if self.sleep_before is not None: + out["sleep_before"] = self.sleep_before + if self.sleep_after is not None: + out["sleep_after"] = self.sleep_after + if self.environment: + out["environment"] = dict(self.environment) + if self.shell is not None: + out["shell"] = self.shell + return out + @dataclass(frozen=True) class Window: @@ -168,6 +206,28 @@ class Window: window_shell: str | None = None panes: Sequence[Pane] = () + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical tmuxp window config (inverse of the analyzer).""" + out: dict[str, t.Any] = {} + if self.name is not None: + out["window_name"] = self.name + if self.layout is not None: + out["layout"] = self.layout + if self.start_directory is not None: + out["start_directory"] = self.start_directory + if self.focus: + out["focus"] = True + if self.options: + out["options"] = dict(self.options) + if self.options_after: + out["options_after"] = dict(self.options_after) + if self.environment: + out["environment"] = dict(self.environment) + if self.window_shell is not None: + out["window_shell"] = self.window_shell + out["panes"] = [pane.to_dict() for pane in self.panes] + return out + @dataclass(frozen=True) class Workspace: @@ -205,6 +265,42 @@ class Workspace: before_script: str | None = None on_exists: t.Literal["error", "replace", "reuse"] = "error" + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical tmuxp workspace dict (the inverse of analyze). + + ``analyze(ws.to_dict())`` reconstructs an equivalent workspace; the output + uses canonical (non-shorthand) keys and omits fields left at their default. + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace( + ... name="dev", + ... windows=[Window("editor", panes=[Pane(run="vim")])], + ... ) + >>> ws.to_dict()["session_name"] + 'dev' + >>> ws.to_dict()["windows"][0]["window_name"] + 'editor' + """ + out: dict[str, t.Any] = {"session_name": self.name} + if self.dimensions is not None: + out["dimensions"] = list(self.dimensions) + if self.start_directory is not None: + out["start_directory"] = self.start_directory + if self.environment: + out["environment"] = dict(self.environment) + if self.options: + out["options"] = dict(self.options) + if self.global_options: + out["global_options"] = dict(self.global_options) + if self.before_script is not None: + out["before_script"] = self.before_script + if self.on_exists != "error": + out["on_exists"] = self.on_exists + out["windows"] = [window.to_dict() for window in self.windows] + return out + def compile(self, *, version: str | None = None) -> LazyPlan: """Lower this declared workspace into a Core ``LazyPlan`` (ops only). diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 864c1fe73..879fa0a6b 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -585,6 +585,34 @@ def test_compile_emits_options_after_following_layout() -> None: assert after_at > layout_at # options_after runs once the layout exists +def test_workspace_to_dict_round_trips_through_analyze() -> None: + """Workspace.to_dict() is the inverse of analyze: an identical compiled plan.""" + ws = Workspace( + name="rt", + dimensions=(120, 40), + environment={"E": "1"}, + options={"history-limit": "5000"}, + global_options={"status-position": "top"}, + on_exists="replace", + windows=[ + Window( + "editor", + layout="main-vertical", + options={"automatic-rename": "off"}, + options_after={"main-pane-width": "120"}, + environment={"WE": "w"}, + panes=[ + Pane(run="vim", environment={"PE": "p"}, suppress_history=False), + Pane(run=[Command("htop", enter=False)], shell="bash"), + ], + ), + Window("logs", window_shell="journalctl -f", panes=[Pane(run="tail")]), + ], + ) + revived = analyze(ws.to_dict()) + assert revived.compile().to_list() == ws.compile().to_list() + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From 92f142af4d98178443ffa44e822c48fb0b305368 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:18:37 -0500 Subject: [PATCH 078/223] Workspace(feat): Add BuildEvent stream + on_event observer why: a faithful replica of tmuxp's on_build_event hooks needs a structural progress stream (session -> windows -> panes -> built) so a caller can drive an incremental UI without polling tmux. what: - events: SessionCreated/WindowCreated/PaneCreated/WorkspaceBuilt union + events_for(op, result) deriving events from bound ids (incl. a creator's implicit children); lives in the Declarative tier, Core plan stays observer-free - runner: build_workspace gains on_event (Callable[[BuildEvent], None]); abuild_workspace gains an awaited async observer; both emit per-op events then a terminal WorkspaceBuilt - ir: Workspace.build/abuild thread on_event through - export the event types from the workspace package - test: identical event stream over sync ConcreteEngine and async AsyncConcreteEngine (sync/async neutrality) --- .../experimental/workspace/__init__.py | 12 +++ src/libtmux/experimental/workspace/events.py | 82 +++++++++++++++++++ src/libtmux/experimental/workspace/ir.py | 22 ++++- src/libtmux/experimental/workspace/runner.py | 25 +++++- ..._async_control_engine_workspace_builder.py | 38 +++++++++ 5 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 src/libtmux/experimental/workspace/events.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 2b4c176a4..aff78a4fa 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -31,17 +31,29 @@ compile_workspace, ) from libtmux.experimental.workspace.confirm import ConfirmReport, confirm +from libtmux.experimental.workspace.events import ( + BuildEvent, + PaneCreated, + SessionCreated, + WindowCreated, + WorkspaceBuilt, +) from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace __all__ = ( + "BuildEvent", "Command", "Compiled", "ConfirmReport", "HostStep", "Pane", + "PaneCreated", + "SessionCreated", "Window", + "WindowCreated", "Workspace", + "WorkspaceBuilt", "WorkspaceCompileError", "abuild_workspace", "analyze", diff --git a/src/libtmux/experimental/workspace/events.py b/src/libtmux/experimental/workspace/events.py new file mode 100644 index 000000000..0899c45c4 --- /dev/null +++ b/src/libtmux/experimental/workspace/events.py @@ -0,0 +1,82 @@ +"""Build events emitted by the workspace runner as it constructs a session. + +These mirror tmuxp's ``on_build_event`` hooks: a structural stream (session -> +windows -> panes -> built) derived from the bound ids as the compiled plan runs, +so a caller can drive an incremental UI without polling tmux. The events live in +the *Declarative* tier -- the runner emits them; the Core +:class:`~libtmux.experimental.ops.plan.LazyPlan` stays observer-free. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class SessionCreated: + """The session was created.""" + + session_id: str + + +@dataclass(frozen=True) +class WindowCreated: + """A window was created (a fresh window, or the reused first window bound).""" + + window_id: str + + +@dataclass(frozen=True) +class PaneCreated: + """A pane was created (a window's first pane, or a split).""" + + pane_id: str + + +@dataclass(frozen=True) +class WorkspaceBuilt: + """The whole workspace finished building.""" + + session_id: str + + +#: A build event in the order the runner emits them. +BuildEvent = SessionCreated | WindowCreated | PaneCreated | WorkspaceBuilt + + +def events_for(op: Operation[t.Any], result: Result) -> list[BuildEvent]: + """Derive the build events from one executed operation and its result. + + A creator binds its own id plus any implicit children (a new session's first + window/pane, a new window's first pane), so one op can yield several events. + + Examples + -------- + >>> from libtmux.experimental.ops import NewWindow + >>> op = NewWindow(capture_pane=True) + >>> result = op.build_result(returncode=0, stdout=("@5 %6",)) + >>> events_for(op, result) + [WindowCreated(window_id='@5'), PaneCreated(pane_id='%6')] + """ + events: list[BuildEvent] = [] + if op.kind == "new_session": + if result.created_id is not None: + events.append(SessionCreated(result.created_id)) + subids = result.created_subids + if "window" in subids: + events.append(WindowCreated(subids["window"])) + if "pane" in subids: + events.append(PaneCreated(subids["pane"])) + elif op.kind == "new_window": + if result.created_id is not None: + events.append(WindowCreated(result.created_id)) + if "pane" in result.created_subids: + events.append(PaneCreated(result.created_subids["pane"])) + elif op.kind == "split_window" and result.created_id is not None: + events.append(PaneCreated(result.created_id)) + return events diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 0f942ca48..08b74565b 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -33,10 +33,11 @@ from dataclasses import dataclass, field if t.TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Awaitable, Callable, Mapping, Sequence from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops.plan import LazyPlan, PlanResult + from libtmux.experimental.workspace.events import BuildEvent @dataclass(frozen=True) @@ -319,16 +320,24 @@ def build( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, ) -> PlanResult: """Compile and execute this workspace synchronously over *engine*. Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check (e.g. against the stateless ``ConcreteEngine``, which has no real - sessions to detect). + sessions to detect). Pass *on_event* to observe the structural build + stream (see :mod:`~.events`). """ from libtmux.experimental.workspace.runner import build_workspace - return build_workspace(self, engine, version=version, preflight=preflight) + return build_workspace( + self, + engine, + version=version, + preflight=preflight, + on_event=on_event, + ) async def abuild( self, @@ -336,8 +345,12 @@ async def abuild( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, ) -> PlanResult: - """Compile and execute this workspace asynchronously over *engine*.""" + """Compile and execute this workspace asynchronously over *engine*. + + *on_event* is awaited for each build event (see :mod:`~.events`). + """ from libtmux.experimental.workspace.runner import abuild_workspace return await abuild_workspace( @@ -345,4 +358,5 @@ async def abuild( engine, version=version, preflight=preflight, + on_event=on_event, ) diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index e99e4aac3..b800497a6 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -26,11 +26,15 @@ from libtmux.experimental.ops._types import NameRef from libtmux.experimental.ops.plan import PlanResult, _resolve from libtmux.experimental.workspace.compiler import compile_full +from libtmux.experimental.workspace.events import WorkspaceBuilt, events_for if t.TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops.results import Result from libtmux.experimental.workspace.compiler import HostStep + from libtmux.experimental.workspace.events import BuildEvent from libtmux.experimental.workspace.ir import Workspace @@ -89,9 +93,13 @@ def build_workspace( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, ) -> PlanResult: """Compile and execute *ws* synchronously over *engine*. + Pass *on_event* to observe the structural build stream (session -> windows -> + panes -> built) as each operation binds its id. + Examples -------- >>> from libtmux.experimental.engines import ConcreteEngine @@ -114,8 +122,13 @@ def build_workspace( bindings[index] = result.created_id for part, sub in result.created_subids.items(): bindings[index, part] = sub + if on_event is not None: + for event in events_for(op, result): + on_event(event) for step in compiled.host_after.get(index, ()): _run_host_sync(step) + if on_event is not None: + on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) @@ -125,8 +138,13 @@ async def abuild_workspace( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, ) -> PlanResult: - """Compile and execute *ws* asynchronously over *engine* (same resolution).""" + """Compile and execute *ws* asynchronously over *engine* (same resolution). + + *on_event* is awaited for each build event, so an async observer can stream + the structural progress (e.g. through a fastmcp Context). + """ if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) @@ -141,6 +159,11 @@ async def abuild_workspace( bindings[index] = result.created_id for part, sub in result.created_subids.items(): bindings[index, part] = sub + if on_event is not None: + for event in events_for(op, result): + await on_event(event) for step in compiled.host_after.get(index, ()): await _run_host_async(step) + if on_event is not None: + await on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 879fa0a6b..c33829b03 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -19,6 +19,7 @@ import pytest from libtmux.experimental.engines import ( + AsyncConcreteEngine, AsyncControlModeEngine, ConcreteEngine, SubprocessEngine, @@ -38,11 +39,14 @@ SplitWindow, ) from libtmux.experimental.workspace import ( + BuildEvent, Command, HostStep, Pane, + SessionCreated, Window, Workspace, + WorkspaceBuilt, WorkspaceCompileError, analyze, compile_full, @@ -613,6 +617,40 @@ def test_workspace_to_dict_round_trips_through_analyze() -> None: assert revived.compile().to_list() == ws.compile().to_list() +def test_build_emits_event_stream_sync_and_async() -> None: + """on_event streams session -> windows -> panes -> built, sync and async.""" + ws = analyze( + { + "session_name": "ev", + "windows": [ + {"window_name": "a", "panes": ["echo x", "echo y"]}, + {"window_name": "b", "panes": ["echo z"]}, + ], + }, + ) + + sync_events: list[BuildEvent] = [] + ws.build(ConcreteEngine(), preflight=False, on_event=sync_events.append) + + async_events: list[BuildEvent] = [] + + async def collect(event: BuildEvent) -> None: + async_events.append(event) + + async def main() -> None: + await ws.abuild(AsyncConcreteEngine(), preflight=False, on_event=collect) + + asyncio.run(main()) + + # the same stream regardless of sync vs async engine (neutrality) + for events in (sync_events, async_events): + kinds = [type(e).__name__ for e in events] + assert isinstance(events[0], SessionCreated) + assert isinstance(events[-1], WorkspaceBuilt) + assert kinds.count("WindowCreated") == 2 # reused window a + created window b + assert kinds.count("PaneCreated") == 3 # a: first + split; b: first + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From 18b5d6818f0038822a81e94fb37d00a4aa3ad5ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:19:45 -0500 Subject: [PATCH 079/223] Workspace(feat): Add opt-in wait_pane readiness (anti-race) why: tmuxp waits for a pane's shell prompt before sending keys so the first keystrokes are not eaten by an un-initialized shell; without it a slow shell races the first send. Kept opt-in so the fast path stays the default (the few-calls north star). what: - ir: Workspace.wait_pane flag (default False), threaded through analyze/to_dict - compiler: HostStep gains "wait_pane" + a pane SlotRef; emitted before a command pane's first send when wait_pane is on and the pane has no custom shell (mirrors tmuxp's `if pane_shell is None`) - runner: host-step executors resolve the pane ref and poll display-message '#{cursor_x},#{cursor_y}' until the cursor leaves the origin (~2s budget, 50ms cadence), sync and async - tests: wait step emitted per command pane (custom-shell skipped, off by default) + a live subprocess build with wait_pane on --- .../experimental/workspace/analyzer.py | 1 + .../experimental/workspace/compiler.py | 20 ++++++- src/libtmux/experimental/workspace/ir.py | 8 +++ src/libtmux/experimental/workspace/runner.py | 58 ++++++++++++++++--- ..._async_control_engine_workspace_builder.py | 50 ++++++++++++++++ 5 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 574793194..4a70db723 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -48,6 +48,7 @@ def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: windows=windows, before_script=data.get("before_script"), on_exists=data.get("on_exists", "error"), + wait_pane=bool(data.get("wait_pane", False)), ) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 2ddcd6861..39459fabc 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -56,12 +56,18 @@ class WorkspaceCompileError(ValueError): @dataclass(frozen=True) class HostStep: - """A host-side step interleaved by the runner (not a tmux operation).""" + """A host-side step interleaved by the runner (not a tmux operation). - kind: t.Literal["sleep", "script"] + ``"sleep"`` waits *seconds*; ``"script"`` runs *command* in *cwd*; + ``"wait_pane"`` polls *pane* (a :class:`~..ops._types.SlotRef`) until its + shell is ready (the runner resolves the ref and queries the live cursor). + """ + + kind: t.Literal["sleep", "script", "wait_pane"] seconds: float | None = None command: str | None = None cwd: str | None = None + pane: SlotRef | None = None @dataclass(frozen=True) @@ -150,6 +156,16 @@ def _emit_window( ) commands = pane.commands if commands: + if ws.wait_pane and (pane.shell or window.window_shell) is None: + # Wait for the pane's shell prompt before sending keys (anti-race); + # a pane launching a custom shell/command does not get this wait, + # mirroring tmuxp's `if pane_shell is None`. + _schedule_before( + host_after, + pre, + len(plan), + HostStep("wait_pane", pane=target), + ) if pane.sleep_before is not None: _schedule_before( host_after, diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 08b74565b..a59fa1b9c 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -254,6 +254,11 @@ class Workspace: A host shell command run once before building (orchestration). on_exists : {"error", "replace", "reuse"} What to do if a session of this name already exists. + wait_pane : bool + When ``True``, wait for each command-bearing pane's shell to be ready + (its cursor to leave the origin) before sending keys -- the tmuxp + anti-race. Off by default (the fast path); panes with a custom ``shell`` + skip the wait. See :mod:`~.events` and the runner. """ name: str @@ -265,6 +270,7 @@ class Workspace: windows: Sequence[Window] = () before_script: str | None = None on_exists: t.Literal["error", "replace", "reuse"] = "error" + wait_pane: bool = False def to_dict(self) -> dict[str, t.Any]: """Serialize to a canonical tmuxp workspace dict (the inverse of analyze). @@ -299,6 +305,8 @@ def to_dict(self) -> dict[str, t.Any]: out["before_script"] = self.before_script if self.on_exists != "error": out["on_exists"] = self.on_exists + if self.wait_pane: + out["wait_pane"] = True out["windows"] = [window.to_dict() for window in self.windows] return out diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index b800497a6..5513259e0 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -22,7 +22,13 @@ import time import typing as t -from libtmux.experimental.ops import HasSession, KillSession, arun, run +from libtmux.experimental.ops import ( + DisplayMessage, + HasSession, + KillSession, + arun, + run, +) from libtmux.experimental.ops._types import NameRef from libtmux.experimental.ops.plan import PlanResult, _resolve from libtmux.experimental.workspace.compiler import compile_full @@ -38,21 +44,55 @@ from libtmux.experimental.workspace.ir import Workspace -def _run_host_sync(step: HostStep) -> None: +#: Pane-readiness poll budget: ~2s at a 50ms cadence (matches tmuxp's timeout). +_WAIT_PANE_POLLS = 40 +_WAIT_PANE_INTERVAL = 0.05 +_CURSOR_FMT = "#{cursor_x},#{cursor_y}" + + +def _pane_ready(cursor: str) -> bool: + """Whether the pane's cursor has left the origin (its shell prompt drew).""" + return bool(cursor) and cursor != "0,0" + + +def _run_host_sync( + step: HostStep, + engine: TmuxEngine, + bindings: dict[int | tuple[int, str], str], + version: str | None, +) -> None: """Execute one host step synchronously.""" if step.kind == "sleep" and step.seconds is not None: time.sleep(step.seconds) elif step.kind == "script" and step.command is not None: subprocess.run(step.command, shell=True, cwd=step.cwd, check=False) + elif step.kind == "wait_pane" and step.pane is not None: + op = _resolve(DisplayMessage(target=step.pane, message=_CURSOR_FMT), bindings) + for _ in range(_WAIT_PANE_POLLS): + if _pane_ready(run(op, engine, version=version).text): + return + time.sleep(_WAIT_PANE_INTERVAL) -async def _run_host_async(step: HostStep) -> None: +async def _run_host_async( + step: HostStep, + engine: AsyncTmuxEngine, + bindings: dict[int | tuple[int, str], str], + version: str | None, +) -> None: """Execute one host step asynchronously.""" if step.kind == "sleep" and step.seconds is not None: await asyncio.sleep(step.seconds) elif step.kind == "script" and step.command is not None: proc = await asyncio.create_subprocess_shell(step.command, cwd=step.cwd) await proc.wait() + elif step.kind == "wait_pane" and step.pane is not None: + op = _resolve(DisplayMessage(target=step.pane, message=_CURSOR_FMT), bindings) + for _ in range(_WAIT_PANE_POLLS): + result = await arun(op, engine, version=version) + if _pane_ready(result.text): + return + await asyncio.sleep(_WAIT_PANE_INTERVAL) def _preflight_sync(ws: Workspace, engine: TmuxEngine, version: str | None) -> bool: @@ -111,10 +151,10 @@ def build_workspace( if preflight and _preflight_sync(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - for step in compiled.pre: - _run_host_sync(step) bindings: dict[int | tuple[int, str], str] = {} results: list[Result] = [] + for step in compiled.pre: + _run_host_sync(step, engine, bindings, version) for index, op in enumerate(compiled.plan.operations): result = run(_resolve(op, bindings), engine, version=version) results.append(result) @@ -126,7 +166,7 @@ def build_workspace( for event in events_for(op, result): on_event(event) for step in compiled.host_after.get(index, ()): - _run_host_sync(step) + _run_host_sync(step, engine, bindings, version) if on_event is not None: on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) @@ -148,10 +188,10 @@ async def abuild_workspace( if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - for step in compiled.pre: - await _run_host_async(step) bindings: dict[int | tuple[int, str], str] = {} results: list[Result] = [] + for step in compiled.pre: + await _run_host_async(step, engine, bindings, version) for index, op in enumerate(compiled.plan.operations): result = await arun(_resolve(op, bindings), engine, version=version) results.append(result) @@ -163,7 +203,7 @@ async def abuild_workspace( for event in events_for(op, result): await on_event(event) for step in compiled.host_after.get(index, ()): - await _run_host_async(step) + await _run_host_async(step, engine, bindings, version) if on_event is not None: await on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index c33829b03..12317a3f8 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -651,6 +651,56 @@ async def main() -> None: assert kinds.count("PaneCreated") == 3 # a: first + split; b: first +def test_compile_emits_wait_pane_when_enabled() -> None: + """wait_pane=True schedules a wait host step per command pane (skipping shells).""" + ws = Workspace( + name="ws-wait", + wait_pane=True, + windows=[ + Window( + "w", + panes=[ + Pane(run="echo a"), # plain shell -> gets a readiness wait + Pane(run="echo b", shell="bash"), # custom shell -> no wait + ], + ), + ], + ) + compiled = compile_full(ws) + waits = [ + step + for steps in (*compiled.host_after.values(), compiled.pre) + for step in steps + if step.kind == "wait_pane" + ] + assert len(waits) == 1 # the custom-shell pane is skipped + assert waits[0].pane is not None # carries the pane SlotRef to poll + + # off by default: no wait steps emitted + off = Workspace(name="off", windows=[Window("w", panes=[Pane(run="echo a")])]) + assert not any( + step.kind == "wait_pane" + for steps in compile_full(off).host_after.values() + for step in steps + ) + + +def test_workspace_wait_pane_builds_live(session: Session, tmp_path: Path) -> None: + """A wait_pane build polls readiness and still completes over real tmux.""" + spec = analyze( + { + "session_name": "ws-wait-live", + "start_directory": str(tmp_path), + "on_exists": "replace", + "wait_pane": True, + "windows": [{"window_name": "w", "panes": ["echo ready", "echo two"]}], + }, + ) + result = spec.build(SubprocessEngine.for_server(session.server)) + assert result.ok + assert confirm(spec, session.server).ok + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From 36ad3ef448158d1a47094ca9bc57a7eb98e39604 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:22:19 -0500 Subject: [PATCH 080/223] Workspace(feat): Honor explicit Window.window_index placement why: tmuxp's window_index config key places a window at a chosen session index; the builder always appended, ignoring it. what: - ir: Window.window_index (threaded through analyze/to_dict) - compiler: a created window (2..N) with window_index targets new-window at `session:N` by suffixing the session SlotRef (":N"), so the captured window-id binding is preserved -- zero Core change - test: window_index renders new-window -t $1:5 and still binds the id note: window 0 reuses the session's implicit window and keeps the base index; append-into-existing-session mode (tmuxp load -a) is deferred as a follow-up -- it restructures the build flow (no new-session, all windows created) and the fresh-session reuse model is faithful for the common case. --- src/libtmux/experimental/workspace/analyzer.py | 1 + src/libtmux/experimental/workspace/compiler.py | 11 +++++++++-- src/libtmux/experimental/workspace/ir.py | 7 +++++++ ...test_async_control_engine_workspace_builder.py | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 4a70db723..6d227b3c3 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -88,6 +88,7 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: options_after=dict(raw.get("options_after", {}) or {}), environment=dict(raw.get("environment", {}) or {}), window_shell=raw.get("window_shell"), + window_index=raw.get("window_index"), panes=[_pane(p) for p in raw.get("panes", []) or []], ) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 39459fabc..4d9065901 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -25,7 +25,7 @@ from __future__ import annotations import typing as t -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from libtmux.experimental.ops import ( LazyPlan, @@ -264,9 +264,16 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: if window.name is not None: plan.add(RenameWindow(target=window_ref, name=window.name)) else: + # An explicit window_index targets new-window at `session:N`; the + # SlotRef suffix appends ":N" to the captured session id at run time. + create_target = ( + replace(session, suffix=f":{window.window_index}") + if window.window_index is not None + else session + ) slot = plan.add( NewWindow( - target=session, + target=create_target, name=window.name, start_directory=window.start_directory or ws.start_directory, environment=_creator_environment(window) or None, diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index a59fa1b9c..4b16a8125 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -193,6 +193,10 @@ class Window: A shell command to launch in the window's first pane instead of the default shell (``new-window`` trailing command); also the fallback ``shell`` for the window's split panes. + window_index : int or None + Place this window at an explicit session index (``new-window -t + session:N``). Honored only for created windows (2..N); window 0 reuses + the session's implicit window and keeps the session base index. panes : Sequence[Pane] The window's panes (the first reuses the window's implicit pane). """ @@ -205,6 +209,7 @@ class Window: options_after: Mapping[str, str] = field(default_factory=dict) environment: Mapping[str, str] = field(default_factory=dict) window_shell: str | None = None + window_index: int | None = None panes: Sequence[Pane] = () def to_dict(self) -> dict[str, t.Any]: @@ -226,6 +231,8 @@ def to_dict(self) -> dict[str, t.Any]: out["environment"] = dict(self.environment) if self.window_shell is not None: out["window_shell"] = self.window_shell + if self.window_index is not None: + out["window_index"] = self.window_index out["panes"] = [pane.to_dict() for pane in self.panes] return out diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 12317a3f8..51142c7dd 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -685,6 +685,21 @@ def test_compile_emits_wait_pane_when_enabled() -> None: ) +def test_compile_window_index_targets_session_index() -> None: + """window_index places a created window at session:N (capture preserved).""" + ws = Workspace( + name="wi", + windows=[ + Window("a", panes=[Pane(run="echo x")]), + Window("b", window_index=5, panes=[Pane(run="echo y")]), + ], + ) + results = ws.build(ConcreteEngine(), preflight=False).results + new_window = next(r for r in results if r.argv[0] == "new-window") + assert "$1:5" in new_window.argv # targeted at the explicit session index + assert new_window.ok # the -P -F capture still binds the new window id + + def test_workspace_wait_pane_builds_live(session: Session, tmp_path: Path) -> None: """A wait_pane build polls readiness and still completes over real tmux.""" spec = analyze( From 1187eb774bf2f9fe077d5601688e30d9903e109a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:23:29 -0500 Subject: [PATCH 081/223] Mcp(feat): Expose build_workspace on the async server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the async-first control-mode server lacked the Declarative tier — build_workspace was sync-only — so an agent on an async engine could not build a whole workspace in one call (a documented asymmetry). what: - plan_tools: abuild_workspace, the async sibling over analyze(spec).abuild(engine) - fastmcp_adapter: register an async build_workspace on the async server, backed by abuild_workspace (mirrors execute_plan's conditional-variant type:ignore) - export abuild_workspace from the mcp package - test: the async server lists + calls build_workspace offline --- src/libtmux/experimental/mcp/__init__.py | 2 ++ .../experimental/mcp/fastmcp_adapter.py | 21 +++++++++++++++++- src/libtmux/experimental/mcp/plan_tools.py | 22 +++++++++++++++++++ tests/experimental/mcp/test_adapter_async.py | 21 ++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 1c1d96ade..b31bb5d6a 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -31,6 +31,7 @@ PlanOutcome, PlanPreview, ResultSchema, + abuild_workspace, aexecute_plan, build_workspace, execute_plan, @@ -267,6 +268,7 @@ def main(argv: Sequence[str] | None = None) -> None: "SessionResult", "ToolDescriptor", "WindowResult", + "abuild_workspace", "aexecute_plan", "build_workspace", "capture_pane", diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 915f25511..ce5fd752e 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -492,7 +492,26 @@ async def execute_plan( "bindings": outcome.bindings, } + async def build_workspace( + spec: dict[str, t.Any], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build a declarative workspace (the Declarative tier) in one call.""" + outcome = await _plan.abuild_workspace( + spec, + t.cast("AsyncTmuxEngine", engine), + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + tools.append((execute_plan, "mutating")) + tools.append((build_workspace, "mutating")) else: def execute_plan( # type: ignore[misc] @@ -513,7 +532,7 @@ def execute_plan( # type: ignore[misc] "bindings": outcome.bindings, } - def build_workspace( + def build_workspace( # type: ignore[misc] spec: dict[str, t.Any], preflight: bool = True, version: str | None = None, diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py index bc5bc7101..49da2edee 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -142,3 +142,25 @@ def build_workspace( results=[result_to_dict(item) for item in result.results], bindings=bindings_to_dict(result.bindings), ) + + +async def abuild_workspace( + spec: t.Mapping[str, t.Any] | str, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanOutcome: + """Async sibling of :func:`build_workspace` (builds over an async engine). + + Lets the async-first server expose the Declarative tier too, so an agent on a + control-mode engine can build a whole workspace in one call. + """ + from libtmux.experimental.workspace import analyze + + result = await analyze(spec).abuild(engine, version=version, preflight=preflight) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) diff --git a/tests/experimental/mcp/test_adapter_async.py b/tests/experimental/mcp/test_adapter_async.py index 08a7b6904..e34890b3c 100644 --- a/tests/experimental/mcp/test_adapter_async.py +++ b/tests/experimental/mcp/test_adapter_async.py @@ -91,3 +91,24 @@ async def main() -> t.Any: data = asyncio.run(main()) assert data["ok"] is True + + +def test_async_build_workspace_offline() -> None: + """The async server exposes build_workspace, backed by abuild_workspace.""" + spec = { + "session_name": "ws", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + } + + async def main() -> tuple[set[str], t.Any]: + async with fastmcp.Client(_async_server()) as client: + names = {tool.name for tool in await client.list_tools()} + payload = await client.call_tool( + "build_workspace", + {"spec": spec, "preflight": False}, + ) + return names, payload + + names, payload = asyncio.run(main()) + assert "build_workspace" in names # Declarative tier reaches the async server + assert (payload.structured_content or {})["ok"] is True From 97102bf4306bec354696bdaec59d5afdf6fbc322 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:24:31 -0500 Subject: [PATCH 082/223] Mcp(feat[safety]): Add tier constants, resolver, ExpectedToolError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: porting libtmux-mcp's safety surface into the core adapter needs a single source of truth for the safety tiers and the agent-correctable error type, ahead of the middleware and the tag-gate. what: - _safety.py: TAG_readonly/mutating/destructive, VALID_SAFETY_LEVELS, _TIER_LEVELS, resolve_safety_level (None->mutating, valid->verbatim, invalid->warn+readonly fail-safe), ExpectedToolError(ToolError) (log_level=WARNING default + suggestion) — fastmcp+logging deps only, off the framework-agnostic import path - tests: resolver defaults/fail-safe-with-warning + ExpectedToolError --- src/libtmux/experimental/mcp/_safety.py | 95 +++++++++++++++++++++++++ tests/experimental/mcp/test_safety.py | 52 ++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/libtmux/experimental/mcp/_safety.py create mode 100644 tests/experimental/mcp/test_safety.py diff --git a/src/libtmux/experimental/mcp/_safety.py b/src/libtmux/experimental/mcp/_safety.py new file mode 100644 index 000000000..cae9479fd --- /dev/null +++ b/src/libtmux/experimental/mcp/_safety.py @@ -0,0 +1,95 @@ +"""Safety tiers for the MCP tool surface. + +A small, dependency-light core: the three safety-tier tags every tool is +registered under, the ``LIBTMUX_SAFETY`` resolver, and the expected-error type +the middleware demotes to ``WARNING``. Imported only from the fastmcp edge (the +adapter wiring and the middleware), so it stays cycle-free -- fastmcp + logging +only, never the framework-agnostic core. +""" + +from __future__ import annotations + +import logging + +from fastmcp.exceptions import ToolError + +logger = logging.getLogger(__name__) + +#: Safety-tier tags -- the string every tool is registered under. +TAG_READONLY = "readonly" +TAG_MUTATING = "mutating" +TAG_DESTRUCTIVE = "destructive" + +#: The recognized ``LIBTMUX_SAFETY`` values. +VALID_SAFETY_LEVELS: frozenset[str] = frozenset( + {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE}, +) + +#: Tier ordering: a tool at level N is allowed when ``N <= the server's max``. +_TIER_LEVELS: dict[str, int] = { + TAG_READONLY: 0, + TAG_MUTATING: 1, + TAG_DESTRUCTIVE: 2, +} + + +def resolve_safety_level(value: str | None) -> str: + """Return the effective safety tier for a ``LIBTMUX_SAFETY`` value. + + Unset defaults to ``"mutating"`` (mutating tools visible, destructive + hidden); a recognized value is honored verbatim; anything else fails *safe* + to ``"readonly"`` with a warning. + + Examples + -------- + >>> resolve_safety_level(None) + 'mutating' + >>> resolve_safety_level("destructive") + 'destructive' + >>> resolve_safety_level("bogus") + 'readonly' + """ + if value is None: + return TAG_MUTATING + if value in VALID_SAFETY_LEVELS: + return value + logger.warning( + "invalid LIBTMUX_SAFETY=%r, falling back to %s", + value, + TAG_READONLY, + ) + return TAG_READONLY + + +class ExpectedToolError(ToolError): + """A ``ToolError`` for expected, agent-correctable failures. + + Defaults ``log_level`` to ``WARNING`` (honored by fastmcp when logging tool + failures) so routine validation errors, missing objects, and tier denials do + not surface as ``ERROR`` records. Carries an optional agent-facing + ``suggestion`` the error-result middleware appends to the result text and + mirrors into the result ``meta``. + + Examples + -------- + >>> import logging + >>> ExpectedToolError("Pane not found: %5").log_level == logging.WARNING + True + >>> ExpectedToolError("noisy", log_level=logging.INFO).log_level == logging.INFO + True + >>> isinstance(ExpectedToolError("x"), ToolError) + True + >>> ExpectedToolError("x", suggestion="Call list_panes.").suggestion + 'Call list_panes.' + >>> ExpectedToolError("no hint").suggestion is None + True + """ + + def __init__( + self, + *args: object, + log_level: int = logging.WARNING, + suggestion: str | None = None, + ) -> None: + super().__init__(*args, log_level=log_level) + self.suggestion = suggestion diff --git a/tests/experimental/mcp/test_safety.py b/tests/experimental/mcp/test_safety.py new file mode 100644 index 000000000..e580d4bc1 --- /dev/null +++ b/tests/experimental/mcp/test_safety.py @@ -0,0 +1,52 @@ +"""Tests for the MCP safety-tier core (resolver + ExpectedToolError).""" + +from __future__ import annotations + +import logging + +import pytest + +pytest.importorskip("fastmcp") + + +def test_resolve_safety_level_default_is_mutating() -> None: + """An unset LIBTMUX_SAFETY defaults to the mutating tier.""" + from libtmux.experimental.mcp._safety import TAG_MUTATING, resolve_safety_level + + assert resolve_safety_level(None) == TAG_MUTATING + + +def test_resolve_safety_level_honors_valid_values() -> None: + """Each recognized tier resolves to itself.""" + from libtmux.experimental.mcp._safety import ( + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + resolve_safety_level, + ) + + for tier in (TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE): + assert resolve_safety_level(tier) == tier + + +def test_resolve_safety_level_invalid_fails_safe_with_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """An invalid value falls back to readonly and warns (fail-safe).""" + from libtmux.experimental.mcp._safety import TAG_READONLY, resolve_safety_level + + with caplog.at_level(logging.WARNING, logger="libtmux.experimental.mcp._safety"): + assert resolve_safety_level("bogus") == TAG_READONLY + assert any( + record.levelno == logging.WARNING and "LIBTMUX_SAFETY" in record.getMessage() + for record in caplog.records + ) + + +def test_expected_tool_error_carries_suggestion() -> None: + """ExpectedToolError defaults to WARNING and stores a suggestion.""" + from libtmux.experimental.mcp._safety import ExpectedToolError + + err = ExpectedToolError("Pane not found", suggestion="Call list_panes.") + assert err.log_level == logging.WARNING + assert err.suggestion == "Call list_panes." From 2c13b8debf641f577ab0968eb88cbbd6ffb97211 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:30:10 -0500 Subject: [PATCH 083/223] Mcp(feat[middleware]): Port error-result + tail-preserving limiter why: fastmcp's stock transform funnels every expected failure through a -32603 "Internal error:" catch-all, and its response limiter drops the tail (terminal scrollback's useful output is at the bottom). what: - new mcp/middleware.py with the real fastmcp base-class imports - ToolErrorResultMiddleware: tool failures -> ToolResult(is_error) with the clean message + typed meta (error_type/expected/suggestion); _log_error demotes ExpectedToolError + schema-validation to WARNING - TailPreservingResponseLimitingMiddleware: keeps the tail, prefixes a truncation header, re-attaches is_error the base path drops - the schema-validation + suggestion helpers (no raw input echoed), _RESPONSE_LIMITED_TOOLS (engine-ops scrollback tools) - dropped libtmux-mcp's global fastmcp-log-filter side effect - tests: tail-keep, error-result meta/suggestion, schema redaction --- src/libtmux/experimental/mcp/middleware.py | 348 ++++++++++++++++++ .../mcp/test_middleware_errors.py | 82 +++++ 2 files changed, 430 insertions(+) create mode 100644 src/libtmux/experimental/mcp/middleware.py create mode 100644 tests/experimental/mcp/test_middleware_errors.py diff --git a/src/libtmux/experimental/mcp/middleware.py b/src/libtmux/experimental/mcp/middleware.py new file mode 100644 index 000000000..0d598df12 --- /dev/null +++ b/src/libtmux/experimental/mcp/middleware.py @@ -0,0 +1,348 @@ +"""fastmcp middleware for the engine-ops MCP server. + +Ported from libtmux-mcp, adapted to the engine-ops adapter (the only +``libtmux_mcp`` dependency was the safety core, now :mod:`._safety`). In +outer-to-inner stack order: + +* :class:`SafetyMiddleware` gates tools by safety tier (see :mod:`._safety`). +* :class:`ToolErrorResultMiddleware` converts tool-call failures into + ``ToolResult(is_error=True)`` carrying the clean message + a structured + ``meta`` payload, instead of fastmcp's ``-32603`` "Internal error: " catch-all. +* :class:`AuditMiddleware` emits one structured log record per tool call, with + payload-bearing arguments redacted to a length + SHA-256 prefix. +* :class:`ReadonlyRetryMiddleware` retries transient libtmux failures, but only + for readonly tools (re-running a mutating tool would double side effects). +* :class:`TailPreservingResponseLimitingMiddleware` caps oversized output while + keeping the **tail** -- terminal scrollback's useful output is at the bottom. + +This module is imported only from the fastmcp edge (the adapter builders), so it +imports the real fastmcp base classes at module top. +""" + +from __future__ import annotations + +import logging +import typing as t + +from fastmcp.server.middleware import MiddlewareContext +from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware +from fastmcp.tools.base import ToolResult +from mcp.types import CallToolRequestParams, TextContent +from pydantic import ValidationError as PydanticValidationError + +from libtmux.experimental.mcp._safety import ExpectedToolError + +#: Curated scrollback tools whose output the tail-preserving limiter backstops. +#: Only terminal-text tools benefit; structured list/get responses stay under the +#: cap naturally. +_RESPONSE_LIMITED_TOOLS: tuple[str, ...] = ( + "capture_pane", + "capture_active_pane", + "grep_pane", + "search_panes", + "show_buffer", + "capture_relative_pane", + "grep_relative_pane", +) + +#: Default byte ceiling -- matches fastmcp's stock 1 MB so normal schema-bearing +#: responses stay below this global backstop. +DEFAULT_RESPONSE_LIMIT_BYTES = 1_000_000 + + +# --------------------------------------------------------------------------- +# Tool-error result conversion +# --------------------------------------------------------------------------- + + +def _schema_validation_error( + error: BaseException, +) -> PydanticValidationError | None: + """Return the Pydantic validation error behind a schema failure.""" + if isinstance(error, PydanticValidationError): + return error + cause = error.__cause__ + if isinstance(cause, PydanticValidationError): + return cause + return None + + +def _is_schema_validation_error(error: BaseException) -> bool: + """Return True for fastmcp argument-schema validation failures. + + fastmcp validates tool arguments against the input schema *before* tool code + runs, raising a bare :exc:`pydantic.ValidationError`. Bad arguments are + agent-correctable, so they get the same expected/WARNING treatment as + :class:`~._safety.ExpectedToolError`. + """ + return _schema_validation_error(error) is not None + + +def _validation_errors_without_inputs( + error: PydanticValidationError, +) -> list[dict[str, t.Any]]: + """Return validation errors without rejected input values.""" + return t.cast( + "list[dict[str, t.Any]]", + error.errors( + include_url=False, + include_context=False, + include_input=False, + ), + ) + + +def _format_schema_validation_error(error: BaseException) -> str: + """Format a Pydantic validation error without raw input values.""" + err = _schema_validation_error(error) + if err is None: + return str(error) + count = err.error_count() + noun = "validation error" if count == 1 else "validation errors" + lines = [f"{count} {noun} for {err.title}"] + for item in _validation_errors_without_inputs(err): + loc = ".".join(str(part) for part in item.get("loc", ())) or "__root__" + msg = str(item.get("msg", "Input validation failed")) + error_type = str(item.get("type", "unknown")) + lines.extend((loc, f" {msg} [type={error_type}]")) + return "\n".join(lines) + + +#: Scheduling flag some MCP clients (notably Gemini CLI batching tool calls) +#: merge into a tool's arguments. Recognized only to *word the rejection +#: helpfully* -- the argument is still rejected, never silently stripped. +_CLIENT_SCHEDULING_FLAG = "wait_for_previous" + + +def _unexpected_kwargs(error: BaseException) -> list[str]: + """Argument names rejected as unexpected by schema validation.""" + err = _schema_validation_error(error) + if err is None: + return [] + return [ + str(item["loc"][-1]) + for item in err.errors() + if item.get("type") == "unexpected_keyword_argument" and item.get("loc") + ] + + +def _client_label(context: MiddlewareContext | None) -> str | None: + """``"name version"`` of the connected client, when the handshake exposed it. + + Every hop can be absent (unit-test contexts, background tasks, clients that + omit ``clientInfo``), so any failure resolves to ``None``. Used only to word + error suggestions; never gates behavior. + """ + if context is None: + return None + try: + fastmcp_ctx = context.fastmcp_context + if fastmcp_ctx is None: + return None + params = fastmcp_ctx.session.client_params + if params is None: + return None + info = params.clientInfo + return f"{info.name} {info.version}".strip() + except (AttributeError, RuntimeError): + return None + + +def _error_tool_result( + error: Exception, + context: MiddlewareContext | None = None, +) -> ToolResult: + """Build a rich ``ToolResult(is_error=True)`` from a tool failure. + + The text carries the error message exactly as raised -- no transform-layer + prefix -- with the recovery ``suggestion`` appended when available. ``meta`` + mirrors the details: ``error_type`` (the ``__cause__`` class when chained, so + agents see ``PaneNotFound`` not the wrapper), ``expected`` (True for + agent-correctable failures), and ``suggestion`` (carried by the error or + synthesized for rejected unexpected arguments). ``structured_content`` is + left unset so an error-shaped payload never fails a strict client's + output-schema validation. + """ + cause = error.__cause__ + origin = cause if cause is not None else error + meta: dict[str, t.Any] = { + "error_type": type(origin).__name__, + "expected": isinstance(error, ExpectedToolError) + or _is_schema_validation_error(error), + } + text = ( + _format_schema_validation_error(error) + if _is_schema_validation_error(error) + else str(error) + ) + suggestion = getattr(error, "suggestion", None) + if suggestion is None: + unknown = _unexpected_kwargs(error) + if unknown: + suggestion = ( + f"Remove or correct the unrecognized argument(s): {', '.join(unknown)}." + ) + if _CLIENT_SCHEDULING_FLAG in unknown: + client = _client_label(context) + who = ( + f"your client ({client})" + if client + else "some clients (e.g. Gemini CLI)" + ) + suggestion += ( + f" {_CLIENT_SCHEDULING_FLAG} is a scheduling flag {who} can " + f"leak into batched tool calls; retry the call without it." + ) + if suggestion: + meta["suggestion"] = suggestion + text = f"{text}\n{suggestion}" + return ToolResult( + content=[TextContent(type="text", text=text)], + meta=meta, + is_error=True, + ) + + +class ToolErrorResultMiddleware(ErrorHandlingMiddleware): + """Convert tool-call failures into rich ``ToolResult`` errors. + + Replaces the stock ``transform_errors`` ``-32603`` catch-all (which mangled + every expected failure message into ``"Internal error: ..."``) for + ``tools/call`` only; non-tool messages fall through to the inherited + ``on_message`` (preserving the MCP ``-32002`` resource-not-found transform). + + Ordering invariant: must sit **outside** ``AuditMiddleware``, + ``ReadonlyRetryMiddleware``, and ``SafetyMiddleware`` -- all three depend on + the failure still being an exception, so converting it to a result deeper in + the stack would silently break them. + """ + + def _log_error(self, error: Exception, context: MiddlewareContext) -> None: + """Log at the error's own ``log_level`` instead of a flat ERROR. + + Expected failures (``ExpectedToolError``, argument-schema validation) log + at WARNING; everything else at ERROR. + """ + level: int | None = getattr(error, "log_level", None) + if level is None: + level = ( + logging.WARNING if _is_schema_validation_error(error) else logging.ERROR + ) + + error_type = type(error).__name__ + method = context.method or "unknown" + + error_key = f"{error_type}:{method}" + self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 + + error_text = ( + _format_schema_validation_error(error) + if _is_schema_validation_error(error) + else str(error) + ) + self.logger.log( + level, + "Error in %s: %s: %s", + method, + error_type, + error_text, + exc_info=self.include_traceback, + ) + + if self.error_callback: + try: + self.error_callback(error, context) + except Exception: + self.logger.exception("Error in error callback") + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Convert tool-call exceptions into ``is_error`` results.""" + try: + return await call_next(context) + except Exception as error: + self._log_error(error, context) + return _error_tool_result(error, context) + + +# --------------------------------------------------------------------------- +# Tail-preserving response limiter +# --------------------------------------------------------------------------- + +#: Header prefixed to a truncated response. +_TRUNCATION_HEADER_TEMPLATE = "[... truncated {dropped} bytes ...]\n" + + +class TailPreservingResponseLimitingMiddleware(ResponseLimitingMiddleware): + """Response-limiter that keeps the tail of oversized output. + + fastmcp's stock :class:`ResponseLimitingMiddleware` keeps the *head*; that is + exactly wrong for terminal scrollback, where the active prompt and most + recent output live at the **bottom**. This subclass keeps the tail and + prefixes a single truncation-header line. Error results keep their + ``is_error`` flag through truncation (the base path drops it). + """ + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Apply the size cap without dropping ``is_error``.""" + inner: t.Any = None + + async def _capture( + context: MiddlewareContext[CallToolRequestParams], + ) -> ToolResult: + # ``context`` (not ``ctx``): fastmcp's CallNext protocol matches the + # parameter *name*, not just the shape -- renaming breaks dispatch. + nonlocal inner + inner = await call_next(context) + return t.cast("ToolResult", inner) + + result = await super().on_call_tool(context, _capture) + if result is not inner and isinstance(inner, ToolResult) and inner.is_error: + return ToolResult( + content=result.content, + meta=result.meta, + is_error=True, + ) + return result + + def _truncate_to_result( + self, + text: str, + meta: dict[str, t.Any] | None = None, + ) -> ToolResult: + """Keep the last ``max_size`` bytes of ``text`` and prefix a header.""" + encoded = text.encode("utf-8") + if len(encoded) <= self.max_size: + return ToolResult( + content=[TextContent(type="text", text=text)], + meta=meta if meta is not None else {}, + ) + + header = _TRUNCATION_HEADER_TEMPLATE.format(dropped=len(encoded)) + header_bytes = len(header.encode("utf-8")) + overhead = 50 # JSON-wrapper accounting, mirrors the base class + target_size = self.max_size - header_bytes - overhead + if target_size <= 0: + return ToolResult( + content=[TextContent(type="text", text=header.rstrip("\n"))], + meta=meta if meta is not None else {}, + ) + + # errors="ignore" so a split UTF-8 sequence at the boundary is dropped + # rather than corrupting the output. + tail = encoded[-target_size:].decode("utf-8", errors="ignore") + dropped = len(encoded) - len(tail.encode("utf-8")) + final_header = _TRUNCATION_HEADER_TEMPLATE.format(dropped=dropped) + truncated = final_header + tail + return ToolResult( + content=[TextContent(type="text", text=truncated)], + meta=meta if meta is not None else {}, + ) diff --git a/tests/experimental/mcp/test_middleware_errors.py b/tests/experimental/mcp/test_middleware_errors.py new file mode 100644 index 000000000..251f57d3e --- /dev/null +++ b/tests/experimental/mcp/test_middleware_errors.py @@ -0,0 +1,82 @@ +"""Tests for the error-result + tail-preserving response middleware.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("fastmcp") + + +def test_truncate_keeps_tail_with_header() -> None: + """The limiter drops the head and keeps the tail behind a header.""" + from mcp.types import TextContent + + from libtmux.experimental.mcp.middleware import ( + TailPreservingResponseLimitingMiddleware, + ) + + mw = TailPreservingResponseLimitingMiddleware(max_size=200) + result = mw._truncate_to_result("HEAD" + "x" * 1000 + "TAILEND") + block = result.content[0] + assert isinstance(block, TextContent) + assert block.text.startswith("[... truncated") + assert block.text.endswith("TAILEND") # tail preserved, head dropped + assert len(block.text.encode("utf-8")) <= 200 + + +def test_error_result_carries_message_and_meta() -> None: + """A plain failure becomes an is_error result with typed meta, no prefix.""" + from mcp.types import TextContent + + from libtmux.experimental.mcp.middleware import _error_tool_result + + result = _error_tool_result(ValueError("boom")) + block = result.content[0] + assert result.is_error is True + assert result.meta is not None + assert result.meta["error_type"] == "ValueError" + assert result.meta["expected"] is False + assert isinstance(block, TextContent) + assert block.text == "boom" + + +def test_error_result_appends_suggestion() -> None: + """An ExpectedToolError's suggestion lands in text + meta, marked expected.""" + from mcp.types import TextContent + + from libtmux.experimental.mcp._safety import ExpectedToolError + from libtmux.experimental.mcp.middleware import _error_tool_result + + result = _error_tool_result( + ExpectedToolError("Pane not found", suggestion="Call list_panes."), + ) + block = result.content[0] + assert result.meta is not None + assert result.meta["expected"] is True + assert result.meta["suggestion"] == "Call list_panes." + assert isinstance(block, TextContent) + assert block.text.endswith("Call list_panes.") + + +def test_schema_validation_error_formats_without_raw_input() -> None: + """Schema-validation failures are expected and never echo the raw input.""" + import pydantic + + from libtmux.experimental.mcp.middleware import ( + _format_schema_validation_error, + _is_schema_validation_error, + ) + + class Model(pydantic.BaseModel): + x: int + + try: + Model(x="not-an-int") # type: ignore[arg-type] + except pydantic.ValidationError as exc: + assert _is_schema_validation_error(exc) + formatted = _format_schema_validation_error(exc) + assert "x" in formatted + assert "[type=" in formatted + assert "not-an-int" not in formatted # raw input is redacted + else: + pytest.fail("expected a ValidationError") From 13c352829ad435da4ff8c91980179aaecd72204b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:38:09 -0500 Subject: [PATCH 084/223] Mcp(feat[middleware]): Port safety, audit, readonly-retry middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: complete the middleware stack — the runtime safety gate (defense in depth behind the static tag-gate), a structured audit trail, and retries scoped to readonly tools so a transient socket error never double-runs a mutating tool. what: - SafetyMiddleware: fail-closed tier gate on list + call (untagged tool denied); raises ExpectedToolError on an over-tier call - AuditMiddleware: one INFO record per call, restructured to the project logging standard (static message + structured extra: tmux_subcommand/ outcome/duration_ms/tmux_args), payload args digested (len+sha256) - ReadonlyRetryMiddleware: composes fastmcp RetryMiddleware, delegates only for readonly-tagged tools; trigger LibTmuxException - loggers namespaced libtmux.experimental.mcp.audit/.retry - single tier source: _TIER_LEVELS/TAG_* imported from _safety - tests: audit redaction, fail-closed _is_allowed, retry pass-through --- src/libtmux/experimental/mcp/middleware.py | 327 +++++++++++++++++- .../experimental/mcp/test_middleware_audit.py | 85 +++++ 2 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 tests/experimental/mcp/test_middleware_audit.py diff --git a/src/libtmux/experimental/mcp/middleware.py b/src/libtmux/experimental/mcp/middleware.py index 0d598df12..d7510e03c 100644 --- a/src/libtmux/experimental/mcp/middleware.py +++ b/src/libtmux/experimental/mcp/middleware.py @@ -21,17 +21,27 @@ from __future__ import annotations +import hashlib import logging +import time import typing as t -from fastmcp.server.middleware import MiddlewareContext -from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware +from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.middleware.error_handling import ( + ErrorHandlingMiddleware, + RetryMiddleware, +) from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware from fastmcp.tools.base import ToolResult from mcp.types import CallToolRequestParams, TextContent from pydantic import ValidationError as PydanticValidationError -from libtmux.experimental.mcp._safety import ExpectedToolError +from libtmux import exc as libtmux_exc +from libtmux.experimental.mcp._safety import ( + _TIER_LEVELS, + TAG_READONLY, + ExpectedToolError, +) #: Curated scrollback tools whose output the tail-preserving limiter backstops. #: Only terminal-text tools benefit; structured list/get responses stay under the @@ -346,3 +356,314 @@ def _truncate_to_result( content=[TextContent(type="text", text=truncated)], meta=meta if meta is not None else {}, ) + + +# --------------------------------------------------------------------------- +# Safety tier gate (runtime) +# --------------------------------------------------------------------------- + + +class SafetyMiddleware(Middleware): + """Gate tools by safety tier at runtime (defense in depth). + + The adapter hides over-tier tools from listings statically; this middleware + blocks *execution* of anything that slips through (e.g. a per-op tool exposed + via ``expose_operations=True``). Fail-closed: a tool with no recognized tier + tag is denied. + + Parameters + ---------- + max_tier : str + Maximum allowed tier (one of the ``TAG_*`` values in :mod:`._safety`). + """ + + def __init__(self, max_tier: str) -> None: + self.max_level = _TIER_LEVELS.get(max_tier, 0) + + def _is_allowed(self, tags: set[str]) -> bool: + """Whether the tool's tags fall within the allowed tier (fail-closed).""" + found_tier = False + for tier, level in _TIER_LEVELS.items(): + if tier in tags: + found_tier = True + if level > self.max_level: + return False + return found_tier + + async def on_list_tools( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Filter tools above the safety tier from the listing.""" + tools = await call_next(context) + return [tool for tool in tools if self._is_allowed(tool.tags)] + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Block execution of tools above the safety tier.""" + if context.fastmcp_context: + tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) + if tool and not self._is_allowed(tool.tags): + msg = ( + f"Tool '{context.message.name}' is not available at the current " + f"safety level. Set LIBTMUX_SAFETY=destructive to enable " + f"destructive tools." + ) + raise ExpectedToolError(msg) + return await call_next(context) + + +# --------------------------------------------------------------------------- +# Audit middleware +# --------------------------------------------------------------------------- + +#: Argument names that carry user payloads we never want in logs (commands, +#: secrets, arbitrary large strings). Matched by exact name, case-sensitive. +#: ``environment`` is dict-shaped: its values are digested while its keys (env +#: var names) stay visible. +_SENSITIVE_ARG_NAMES: frozenset[str] = frozenset( + {"keys", "text", "command", "value", "content", "shell", "environment"}, +) + +#: Nested argument containers that may contain sensitive argument names. +_NESTED_ARG_LIST_NAMES: frozenset[str] = frozenset({"operations"}) + +_NONE_TYPE = type(None) + +_SEND_KEYS_OPERATION_ARG_TYPES: dict[str, tuple[type[t.Any], ...]] = { + "keys": (str,), + "pane_id": (str, _NONE_TYPE), + "session_name": (str, _NONE_TYPE), + "session_id": (str, _NONE_TYPE), + "window_id": (str, _NONE_TYPE), + "enter": (bool,), + "literal": (bool,), + "suppress_history": (bool,), +} + +#: Non-sensitive strings longer than this get truncated in the log summary. +_MAX_LOGGED_STR_LEN: int = 200 + + +def _redact_digest(value: str) -> dict[str, t.Any]: + """Return a length + SHA-256 prefix summary of ``value``. + + Stable and deterministic, so operators correlate the same payload across log + lines without ever recording the payload itself. + + Examples + -------- + >>> _redact_digest("hello") + {'len': 5, 'sha256_prefix': '2cf24dba5fb0'} + >>> _redact_digest("") + {'len': 0, 'sha256_prefix': 'e3b0c44298fc'} + """ + return { + "len": len(value), + "sha256_prefix": hashlib.sha256(value.encode("utf-8")).hexdigest()[:12], + } + + +def _redacted_value_shape(value: t.Any) -> dict[str, t.Any]: + """Return non-payload metadata for a value that cannot be logged.""" + return {"type": type(value).__name__, "redacted": True} + + +def _summarize_send_keys_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize one send-keys batch operation for audit logging.""" + summary: dict[str, t.Any] = {} + for key, value in args.items(): + expected_types = _SEND_KEYS_OPERATION_ARG_TYPES.get(key) + if expected_types is None or not isinstance(value, expected_types): + summary[key] = _redacted_value_shape(value) + else: + summary[key] = _summarize_args({key: value})[key] + return summary + + +def _summarize_tool_batch_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize one generic tool-batch operation for audit logging.""" + summary: dict[str, t.Any] = {} + for key, value in args.items(): + if key == "tool" and isinstance(value, str): + summary[key] = value + elif key == "arguments" and isinstance(value, dict): + summary[key] = _summarize_args(value) + else: + summary[key] = _redacted_value_shape(value) + return summary + + +def _summarize_nested_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize a known nested operation shape.""" + if "tool" in args or "arguments" in args: + return _summarize_tool_batch_operation_args(args) + return _summarize_send_keys_operation_args(args) + + +def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize tool arguments for audit logging. + + Sensitive keys are replaced by a digest; over-long strings truncated; + everything else passes through. Dict-shaped sensitive values keep their keys + but digest each value; known nested operation lists are summarized + recursively. + + Examples + -------- + >>> _summarize_args({"pane_id": "%1", "bracket": True}) + {'pane_id': '%1', 'bracket': True} + >>> _summarize_args({"keys": "rm -rf /"})["keys"]["len"] + 8 + >>> redacted = _summarize_args({"environment": {"FOO": "bar"}}) + >>> redacted["environment"]["FOO"]["len"] + 3 + >>> "bar" in str(redacted) + False + """ + summary: dict[str, t.Any] = {} + for key, value in args.items(): + if key in _SENSITIVE_ARG_NAMES and isinstance(value, str): + summary[key] = _redact_digest(value) + elif key in _SENSITIVE_ARG_NAMES and isinstance(value, dict): + summary[key] = {k: _redact_digest(str(v)) for k, v in value.items()} + elif key in _NESTED_ARG_LIST_NAMES: + if isinstance(value, list): + summary[key] = [ + _summarize_nested_operation_args(item) + if isinstance(item, dict) + else _redacted_value_shape(item) + for item in value + ] + else: + summary[key] = _redacted_value_shape(value) + elif isinstance(value, str) and len(value) > _MAX_LOGGED_STR_LEN: + summary[key] = value[:_MAX_LOGGED_STR_LEN] + "..." + else: + summary[key] = value + return summary + + +class AuditMiddleware(Middleware): + """Emit a structured log record per tool invocation. + + One ``INFO`` record per call carries the tool name, outcome, duration, error + type on failure, the fastmcp client/request ids when available, and a + redacted argument summary -- all in the record's ``extra`` (the message is a + static template, per the project logging standard), so payload-bearing + arguments never reach the log as raw text. + + Parameters + ---------- + logger_name : str + Name of the :mod:`logging` logger used for audit records. + """ + + def __init__( + self, + logger_name: str = "libtmux.experimental.mcp.audit", + ) -> None: + self._logger = logging.getLogger(logger_name) + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Wrap the tool call with a timer and emit one audit record.""" + start = time.monotonic() + tool_name = getattr(context.message, "name", "") + raw_args = getattr(context.message, "arguments", None) or {} + args_summary = _summarize_args(raw_args) + + client_id: str | None = None + request_id: str | None = None + if context.fastmcp_context is not None: + client_id = getattr(context.fastmcp_context, "client_id", None) + request_id = getattr(context.fastmcp_context, "request_id", None) + + try: + result = await call_next(context) + except Exception as exc: + self._logger.info( + "tool call failed", + extra={ + "tmux_subcommand": tool_name, + "outcome": "error", + "error_type": type(exc).__name__, + "duration_ms": round((time.monotonic() - start) * 1000.0, 2), + "client_id": client_id, + "request_id": request_id, + "tmux_args": args_summary, + }, + ) + raise + + self._logger.info( + "tool call completed", + extra={ + "tmux_subcommand": tool_name, + "outcome": "ok", + "duration_ms": round((time.monotonic() - start) * 1000.0, 2), + "client_id": client_id, + "request_id": request_id, + "tmux_args": args_summary, + }, + ) + return result + + +# --------------------------------------------------------------------------- +# Readonly retry +# --------------------------------------------------------------------------- + + +class ReadonlyRetryMiddleware(Middleware): + """Retry transient libtmux failures, but only for readonly tools. + + Composes fastmcp's :class:`RetryMiddleware`. Mutating and destructive tools + pass straight through -- re-running them on a transient socket error would + silently double side effects. Readonly tools are safe to retry. The default + trigger is :class:`libtmux.exc.LibTmuxException` (libtmux wraps the transient + subprocess failures); fastmcp's stock ``(ConnectionError, TimeoutError)`` does + not match these, so the upstream default would be a silent no-op. + + Place this **inside** ``AuditMiddleware`` (so retried calls are audited once) + and **outside** ``SafetyMiddleware`` (so tier-denied tools never reach retry). + """ + + def __init__( + self, + max_retries: int = 1, + base_delay: float = 0.1, + max_delay: float = 1.0, + backoff_multiplier: float = 2.0, + retry_exceptions: tuple[type[Exception], ...] = (libtmux_exc.LibTmuxException,), + logger_: logging.Logger | None = None, + ) -> None: + if logger_ is None: + logger_ = logging.getLogger("libtmux.experimental.mcp.retry") + self._retry = RetryMiddleware( + max_retries=max_retries, + base_delay=base_delay, + max_delay=max_delay, + backoff_multiplier=backoff_multiplier, + retry_exceptions=retry_exceptions, + logger=logger_, + ) + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Delegate to the upstream retry only for tools tagged readonly.""" + if context.fastmcp_context: + tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) + if tool and TAG_READONLY in tool.tags: + return await self._retry.on_request(context, call_next) + return await call_next(context) diff --git a/tests/experimental/mcp/test_middleware_audit.py b/tests/experimental/mcp/test_middleware_audit.py new file mode 100644 index 000000000..b503f8b97 --- /dev/null +++ b/tests/experimental/mcp/test_middleware_audit.py @@ -0,0 +1,85 @@ +"""Tests for the audit, safety-tier, and readonly-retry middleware.""" + +from __future__ import annotations + +import asyncio +import logging +import typing as t +from types import SimpleNamespace + +import pytest + +pytest.importorskip("fastmcp") + + +def test_audit_emits_one_redacted_record(caplog: pytest.LogCaptureFixture) -> None: + """One structured INFO record per call, with sensitive args digested.""" + from libtmux.experimental.mcp.middleware import AuditMiddleware + + mw = AuditMiddleware() + ctx: t.Any = SimpleNamespace( + message=SimpleNamespace( + name="send_input", + arguments={"keys": "secret-cmd", "pane_id": "%1"}, + ), + fastmcp_context=SimpleNamespace(client_id="c1", request_id="r1"), + ) + + async def call_next(_context: t.Any) -> str: + return "ok" + + async def main() -> t.Any: + return await mw.on_call_tool(ctx, call_next) + + with caplog.at_level(logging.INFO, logger="libtmux.experimental.mcp.audit"): + result = asyncio.run(main()) + + assert result == "ok" + records = [ + r for r in caplog.records if getattr(r, "tmux_subcommand", None) == "send_input" + ] + assert len(records) == 1 + record = records[0] + assert record.outcome == "ok" # type: ignore[attr-defined] + assert isinstance(record.duration_ms, float) # type: ignore[attr-defined] + # the sensitive `keys` payload is digested, never logged raw + summary = record.tmux_args # type: ignore[attr-defined] + assert "secret-cmd" not in str(summary) + assert summary["keys"]["sha256_prefix"] + assert summary["pane_id"] == "%1" # non-sensitive args pass through + + +def test_safety_is_allowed_is_fail_closed() -> None: + """A tool at or below the tier is allowed; an untagged tool is denied.""" + from libtmux.experimental.mcp._safety import ( + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + ) + from libtmux.experimental.mcp.middleware import SafetyMiddleware + + mw = SafetyMiddleware(TAG_MUTATING) + assert mw._is_allowed({TAG_READONLY}) + assert mw._is_allowed({TAG_MUTATING}) + assert not mw._is_allowed({TAG_DESTRUCTIVE}) # over tier + assert not mw._is_allowed(set()) # no recognized tier -> denied + + +def test_readonly_retry_passes_through_without_context() -> None: + """With no fastmcp context the retry wrapper is a transparent pass-through.""" + from libtmux.experimental.mcp.middleware import ReadonlyRetryMiddleware + + mw = ReadonlyRetryMiddleware() + ctx: t.Any = SimpleNamespace(fastmcp_context=None) + calls = 0 + + async def call_next(_context: t.Any) -> str: + nonlocal calls + calls += 1 + return "value" + + async def main() -> t.Any: + return await mw.on_call_tool(ctx, call_next) + + assert asyncio.run(main()) == "value" + assert calls == 1 # not retried, just forwarded From 8d305362aea69c4ff7e4917606aa09c8b8c634c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:44:42 -0500 Subject: [PATCH 085/223] Mcp(feat[safety]): Wire safety gate + middleware into the builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: replacing libtmux-mcp needs the safety tier-gate and the middleware stack on the engine-ops servers — gating destructive tools by LIBTMUX_SAFETY (default mutating) and adding the timing/limit/error/ audit/retry/safety chain. what: - _apply_safety_gate (Option A, subtractive): disable only the over-tier tiers AFTER register_operations, so the per-op hide is never undone — destructive op_* stay hidden at every tier (regression-tested) - _make_middleware builds the outer->inner stack (Safety innermost, fail-closed); passed at FastMCP(middleware=...) construction - build_server/build_async_server grow safety_level + include_middleware; level resolved in-body (env read deferred -> monkeypatchable) - main() gains --safety; default_server/main forward it - tests: static visibility per tier, the per-op re-exposure regression, destructive-call blocked at readonly, plan-tool tier - existing kill_*/op_kill_* tests opt into safety_level="destructive" (the new default tier hides destructive tools, as intended) --- src/libtmux/experimental/mcp/__init__.py | 8 ++ .../experimental/mcp/fastmcp_adapter.py | 88 ++++++++++++++++++- tests/experimental/mcp/test_caller.py | 6 +- .../experimental/mcp/test_fastmcp_adapter.py | 13 ++- tests/experimental/mcp/test_safety_gate.py | 82 +++++++++++++++++ 5 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 tests/experimental/mcp/test_safety_gate.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index b31bb5d6a..4a66441ca 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -182,6 +182,12 @@ def main(argv: Sequence[str] | None = None) -> None: action="store_true", help="expose the full per-operation tool surface (op_*)", ) + parser.add_argument( + "--safety", + choices=("readonly", "mutating", "destructive"), + default=None, + help="max tool safety tier (default: $LIBTMUX_SAFETY or mutating)", + ) parser.add_argument( "--sync", action="store_true", @@ -232,6 +238,7 @@ def main(argv: Sequence[str] | None = None) -> None: SubprocessEngine(server_args=srv_args), name=args.name, expose_operations=args.operations, + safety_level=args.safety, caller=ctx, ) else: @@ -243,6 +250,7 @@ def main(argv: Sequence[str] | None = None) -> None: AsyncControlModeEngine(server_args=srv_args), name=args.name, expose_operations=args.operations, + safety_level=args.safety, events=t.cast("EventMode", args.events), event_source=t.cast("EventSource", args.event_source), caller=ctx, diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index ce5fd752e..1d5f11350 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -23,6 +23,7 @@ import dataclasses import inspect +import os import typing as t from libtmux.experimental.mcp import vocabulary @@ -579,6 +580,70 @@ def _stash_caller(engine: t.Any, ctx: CallerContext) -> None: engine._caller_context = ctx +def _make_middleware(level: str) -> list[t.Any]: + """Build the middleware stack (outer-to-inner; Safety innermost, fail-closed). + + Timing observes; the tail-preserving limiter caps oversized scrollback; + ToolErrorResult converts failures to typed error results; Audit records each + call; ReadonlyRetry retries readonly tools; Safety gates execution by tier. + """ + from fastmcp.server.middleware.timing import TimingMiddleware + + from libtmux.experimental.mcp.middleware import ( + _RESPONSE_LIMITED_TOOLS, + DEFAULT_RESPONSE_LIMIT_BYTES, + AuditMiddleware, + ReadonlyRetryMiddleware, + SafetyMiddleware, + TailPreservingResponseLimitingMiddleware, + ToolErrorResultMiddleware, + ) + + return [ + TimingMiddleware(), + TailPreservingResponseLimitingMiddleware( + max_size=DEFAULT_RESPONSE_LIMIT_BYTES, + tools=list(_RESPONSE_LIMITED_TOOLS), + ), + ToolErrorResultMiddleware(transform_errors=True), + AuditMiddleware(), + ReadonlyRetryMiddleware(), + SafetyMiddleware(max_tier=level), + ] + + +def _apply_safety_gate(mcp: FastMCP, max_tier: str) -> None: + """Hide tools above *max_tier* without re-exposing hidden per-op tools. + + Subtractive (never calls ``enable``): disable only the over-tier tiers, so the + per-op hide (``mcp.disable(tags={'per-op'})`` in :func:`register_operations`) + and any individually-disabled tool survive. ``readonly`` is always allowed. + """ + from libtmux.experimental.mcp._safety import ( + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + ) + + allowed = {TAG_READONLY} + if max_tier in {TAG_MUTATING, TAG_DESTRUCTIVE}: + allowed.add(TAG_MUTATING) + if max_tier == TAG_DESTRUCTIVE: + allowed.add(TAG_DESTRUCTIVE) + for tier in (TAG_MUTATING, TAG_DESTRUCTIVE): + if tier not in allowed: + mcp.disable(tags={tier}) + + +def _resolve_level(safety_level: str | None) -> str: + """Resolve the effective tier from an explicit arg or ``LIBTMUX_SAFETY``.""" + from libtmux.experimental.mcp._safety import resolve_safety_level + + return resolve_safety_level( + safety_level if safety_level is not None else os.environ.get("LIBTMUX_SAFETY"), + ) + + def build_server( engine: TmuxEngine, *, @@ -587,6 +652,8 @@ def build_server( include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, + include_middleware: bool = True, + safety_level: str | None = None, caller: CallerContext | None = None, ) -> FastMCP: """Build a synchronous FastMCP server over a sync *engine*. @@ -595,12 +662,22 @@ def build_server( offloads to a worker thread. Prefer :func:`build_async_server` for the async-first surface and the event stream. *caller* defaults to :meth:`CallerContext.discover`. + + *safety_level* (or ``LIBTMUX_SAFETY``) gates the tool surface by tier + (``readonly``/``mutating``/``destructive``, default ``mutating``); over-tier + tools are hidden and blocked. *include_middleware* adds the full stack + (timing, response cap, error results, audit, readonly retry, safety). """ from fastmcp import FastMCP + level = _resolve_level(safety_level) ctx = caller if caller is not None else CallerContext.discover() _stash_caller(engine, ctx) - mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) + mcp: FastMCP = FastMCP( + name=name, + instructions=instructions or _instructions(ctx), + middleware=_make_middleware(level) if include_middleware else None, + ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=False) register_caller_context(mcp, ctx) @@ -614,6 +691,7 @@ def build_server( ) if include_plan_tools: register_plan_tools(mcp, engine, is_async=False, registry=registry) + _apply_safety_gate(mcp, level) return mcp @@ -625,6 +703,8 @@ def build_async_server( include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, + include_middleware: bool = True, + safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", caller: CallerContext | None = None, @@ -636,17 +716,22 @@ def build_async_server( notification stream (a control-mode engine), the live event tools are registered per *events* (``"push"``/``"pull"``/``"both"``/``"off"``). *caller* defaults to :meth:`CallerContext.discover`. + + *safety_level* (or ``LIBTMUX_SAFETY``) and *include_middleware* behave as in + :func:`build_server`. """ from fastmcp import FastMCP from libtmux.experimental.mcp.events import _supports_stream, register_events + level = _resolve_level(safety_level) ctx = caller if caller is not None else CallerContext.discover() _stash_caller(engine, ctx) events_enabled = events != "off" and _supports_stream(engine) mcp: FastMCP = FastMCP( name=name, instructions=instructions or _instructions(ctx, events_enabled=events_enabled), + middleware=_make_middleware(level) if include_middleware else None, ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) @@ -662,4 +747,5 @@ def build_async_server( if include_plan_tools: register_plan_tools(mcp, engine, is_async=True, registry=registry) register_events(mcp, engine, mode=events, source=event_source) + _apply_safety_gate(mcp, level) return mcp diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index 802b9b77a..317b0c342 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -374,7 +374,10 @@ def test_op_kill_pane_is_guarded(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") server = build_async_server( - SyncToAsyncEngine(ConcreteEngine()), events="off", expose_operations=True + SyncToAsyncEngine(ConcreteEngine()), + events="off", + expose_operations=True, + safety_level="destructive", # op_kill_* is destructive-tier ) async def main() -> None: @@ -448,6 +451,7 @@ def test_op_kill_pane_others_is_guarded_live( AsyncSubprocessEngine.for_server(session.server), events="off", expose_operations=True, + safety_level="destructive", # op_kill_* is destructive-tier ) async def main() -> None: diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py index 80dc91278..2fd5ffd43 100644 --- a/tests/experimental/mcp/test_fastmcp_adapter.py +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -27,7 +27,8 @@ def test_adapter_registers_typed_tools() -> None: """The curated vocabulary appears as typed tools with safety annotations.""" - server = build_server(ConcreteEngine()) + # destructive tier so the kill_* tools this asserts on are visible + server = build_server(ConcreteEngine(), safety_level="destructive") async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -73,7 +74,8 @@ async def main() -> t.Any: def test_adapter_live(session: Session) -> None: """Drive a real tmux server through fastmcp tools end to end.""" server = session.server - mcp = build_server(SubprocessEngine.for_server(server)) + # destructive tier so the live kill_session call below is permitted + mcp = build_server(SubprocessEngine.for_server(server), safety_level="destructive") async def main() -> str | None: async with fastmcp.Client(mcp) as client: @@ -112,7 +114,12 @@ async def main() -> t.Any: def test_adapter_exposes_per_op_tools() -> None: """``expose_operations`` reveals one typed ``op_`` per operation.""" - server = build_server(ConcreteEngine(), expose_operations=True) + # destructive tier so the destructive op_kill_* tools are visible too + server = build_server( + ConcreteEngine(), + expose_operations=True, + safety_level="destructive", + ) async def main() -> t.Any: async with fastmcp.Client(server) as client: diff --git a/tests/experimental/mcp/test_safety_gate.py b/tests/experimental/mcp/test_safety_gate.py new file mode 100644 index 000000000..5de29f5f0 --- /dev/null +++ b/tests/experimental/mcp/test_safety_gate.py @@ -0,0 +1,82 @@ +"""Tests for the safety tier-gate wired into the fastmcp adapter.""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine + +fastmcp = pytest.importorskip("fastmcp") + +from libtmux.experimental.mcp.fastmcp_adapter import build_server # noqa: E402 + + +def _names_at(level: str, **kwargs: t.Any) -> set[str]: + """Return the visible tool names from a server built at *level*.""" + + async def main() -> set[str]: + server = build_server(ConcreteEngine(), safety_level=level, **kwargs) + async with fastmcp.Client(server) as client: + return {tool.name for tool in await client.list_tools()} + + return asyncio.run(main()) + + +def test_safety_gate_static_visibility() -> None: + """Each tier hides the tools above it from the listing.""" + readonly = _names_at("readonly") + mutating = _names_at("mutating") + destructive = _names_at("destructive") + + assert "list_sessions" in readonly # readonly always visible + assert "create_session" not in readonly # mutating hidden at readonly + assert "create_session" in mutating # ... visible at mutating + assert "kill_session" not in readonly # destructive hidden ... + assert "kill_session" not in mutating # ... and at mutating + assert "kill_session" in destructive # visible only at destructive + + +def test_safety_gate_keeps_per_op_hidden_at_every_tier() -> None: + """Regression: the subtractive gate never re-exposes hidden per-op tools.""" + for level in ("readonly", "mutating", "destructive"): + names = _names_at(level, include_operations=True, expose_operations=False) + assert not any(name.startswith("op_") for name in names), ( + f"per-op tools leaked into the listing at safety_level={level!r}" + ) + # expose_operations=True surfaces them (and they still respect the tier) + exposed = _names_at( + "destructive", + include_operations=True, + expose_operations=True, + ) + assert any(name.startswith("op_") for name in exposed) + + +def test_safety_gate_blocks_destructive_call_at_readonly() -> None: + """A destructive tool cannot be successfully called at the readonly tier.""" + + async def main() -> tuple[str, t.Any]: + server = build_server(ConcreteEngine(), safety_level="readonly") + async with fastmcp.Client(server) as client: + try: + result = await client.call_tool("kill_session", {"target": "$1"}) + except Exception as error: + return ("raised", type(error).__name__) + return ("result", result.is_error) + + kind, value = asyncio.run(main()) + # Either the hidden tool is rejected (raised) or surfaced as an error result; + # never a clean success. + assert kind == "raised" or value is True + + +def test_safety_gate_plan_tool_tier() -> None: + """Plan tools obey the tier too (build_workspace is mutating).""" + readonly = _names_at("readonly") + mutating = _names_at("mutating") + assert "preview_plan" in readonly # readonly plan tool always visible + assert "build_workspace" not in readonly # mutating plan tool hidden ... + assert "build_workspace" in mutating # ... visible at mutating From 7daa2d89e7af9c5caa5dd9bc024fc202d9550252 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:50:01 -0500 Subject: [PATCH 086/223] Mcp(feat[prompts]): Add recipe prompts in engine-ops vocabulary why: libtmux-mcp ships workflow prompts (run-and-wait, diagnose, build-workspace, interrupt) that package operator-discovered best practices; the engine-ops server should offer the same, in its own vocabulary. what: - prompts.py: the four recipes rewritten over the engine-ops verbs (send_input/wait_for_output/capture_pane/create_session/split_pane), not libtmux-mcp's run_command/snapshot_pane/send_keys/split_window - register_prompts(mcp) via Prompt.from_function; pure string builders, identical on the sync and async servers - both builders gain include_prompts (default True); registered after the caller context - tests: the four prompts register; rendered bodies name only engine-ops tools (guards prompt tool-name drift) --- .../experimental/mcp/fastmcp_adapter.py | 10 +++ src/libtmux/experimental/mcp/prompts.py | 89 +++++++++++++++++++ tests/experimental/mcp/test_prompts.py | 64 +++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/libtmux/experimental/mcp/prompts.py create mode 100644 tests/experimental/mcp/test_prompts.py diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 1d5f11350..9e900fd2f 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -653,6 +653,7 @@ def build_server( expose_operations: bool = False, include_plan_tools: bool = True, include_middleware: bool = True, + include_prompts: bool = True, safety_level: str | None = None, caller: CallerContext | None = None, ) -> FastMCP: @@ -681,6 +682,10 @@ def build_server( registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=False) register_caller_context(mcp, ctx) + if include_prompts: + from libtmux.experimental.mcp.prompts import register_prompts + + register_prompts(mcp) if include_operations: register_operations( mcp, @@ -704,6 +709,7 @@ def build_async_server( expose_operations: bool = False, include_plan_tools: bool = True, include_middleware: bool = True, + include_prompts: bool = True, safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", @@ -736,6 +742,10 @@ def build_async_server( registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) register_caller_context(mcp, ctx) + if include_prompts: + from libtmux.experimental.mcp.prompts import register_prompts + + register_prompts(mcp) if include_operations: register_operations( mcp, diff --git a/src/libtmux/experimental/mcp/prompts.py b/src/libtmux/experimental/mcp/prompts.py new file mode 100644 index 000000000..48c922169 --- /dev/null +++ b/src/libtmux/experimental/mcp/prompts.py @@ -0,0 +1,89 @@ +"""Recipe prompts for the engine-ops MCP server. + +Each function is a FastMCP prompt -- a template returning the text an MCP client +sends to its model, packaging a few common workflows over the engine-ops +vocabulary (``send_input`` / ``wait_for_output`` / ``capture_pane`` / +``create_session`` / ``split_pane``). Pure string builders, engine-agnostic, so +the same set registers on both the sync and async servers. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +def run_and_wait(command: str, pane_id: str, timeout: float = 60.0) -> str: + """Run a shell command in a pane and wait for it to settle.""" + return f"""Run this shell command in tmux pane {pane_id}, then wait for the +pane to settle and inspect the result: + +1. send_input(target={pane_id!r}, keys={command!r}, enter=True) +2. wait_for_output(pane={pane_id!r}, timeout={timeout}) -- folds the live output + and returns when the pane goes quiet (needle-free: no regex, no sentinel). +3. Read done.pane_dead / done.pane_dead_status (exit code) and captured_text. + "Settled" means output stopped, not that the command succeeded -- check the + done metadata for the exit status. + +Prefer this over a send_input + capture_pane retry loop: wait_for_output is +event-backed and reports the process exit.""" + + +def diagnose_failing_pane(pane_id: str) -> str: + """Gather pane context and propose a root-cause hypothesis.""" + return f"""Something went wrong in tmux pane {pane_id}. Diagnose it: + +1. capture_pane(target={pane_id!r}) to read the scrollback (the active prompt and + most recent output are at the bottom). +2. If the pane is still producing output, wait_for_output(pane={pane_id!r}) until + it settles, then capture again. +3. Identify the last command that ran (the prompt line and the line above it) and + the last non-empty output line. +4. Propose a root-cause hypothesis and a minimal command to verify it -- produce + the plan first; do NOT execute anything yet.""" + + +def build_dev_workspace(session_name: str, log_command: str = "watch -n 1 date") -> str: + """Construct a simple 3-pane development session.""" + return f"""Set up a 3-pane development workspace named {session_name!r} +(editor on top, a shell bottom-left, a logs tail bottom-right): + +1. create_session(name={session_name!r}) -- creates the session with one pane + (the editor). Capture its first_pane_id as %A. +2. split_pane(target="%A") -- splits off the bottom half (the terminal, %B). +3. split_pane(target="%B", horizontal=True) -- splits %B (the logs pane, %C). +4. send_input(target="%A", keys="vim", enter=True) and + send_input(target="%C", keys={log_command!r}, enter=True). Leave %B at its + fresh shell prompt. No pre-launch wait is required -- tmux buffers keystrokes + into the PTY whether or not the shell has finished drawing. + +Use pane ids (%N) for all targeting -- they are stable across layout changes; +window renames are not.""" + + +def interrupt_gracefully(pane_id: str) -> str: + r"""Interrupt a running command and verify the prompt returned.""" + return rf"""Interrupt whatever is running in pane {pane_id} and verify that +control returns to the shell: + +1. send_input(target={pane_id!r}, keys="C-c", enter=False) -- tmux interprets + C-c as SIGINT. +2. wait_for_output(pane={pane_id!r}, timeout=5.0) -- wait for the pane to settle + back at a shell prompt. +3. If it does not settle, the process is ignoring SIGINT. Stop and ask the caller + how to proceed -- do NOT escalate automatically to C-\ (SIGQUIT) or kill.""" + + +def register_prompts(mcp: FastMCP) -> None: + """Register the recipe prompts on *mcp*.""" + from fastmcp.prompts import Prompt + + for fn in ( + run_and_wait, + diagnose_failing_pane, + build_dev_workspace, + interrupt_gracefully, + ): + mcp.add_prompt(Prompt.from_function(fn, name=fn.__name__)) diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py new file mode 100644 index 000000000..2d097c621 --- /dev/null +++ b/tests/experimental/mcp/test_prompts.py @@ -0,0 +1,64 @@ +"""Tests for the recipe prompts on the engine-ops MCP server.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from libtmux.experimental.engines import ConcreteEngine + +fastmcp = pytest.importorskip("fastmcp") + +from libtmux.experimental.mcp.fastmcp_adapter import build_server # noqa: E402 + +_NAMES = { + "run_and_wait", + "diagnose_failing_pane", + "build_dev_workspace", + "interrupt_gracefully", +} +#: Tool names from libtmux-mcp that do NOT exist in the engine-ops vocabulary. +_FOREIGN_TOOLS = ( + "run_command", + "snapshot_pane", + "send_keys", + "split_window", + "wait_for_text", + "capture_since", +) + + +def test_prompts_registered() -> None: + """The four recipe prompts are registered on the server.""" + server = build_server(ConcreteEngine()) + + async def main() -> set[str]: + async with fastmcp.Client(server) as client: + return {prompt.name for prompt in await client.list_prompts()} + + assert asyncio.run(main()) >= _NAMES + + +def test_prompt_bodies_use_engine_ops_vocabulary() -> None: + """Rendered prompt text names engine-ops verbs, never libtmux-mcp-only ones.""" + from libtmux.experimental.mcp.prompts import ( + build_dev_workspace, + diagnose_failing_pane, + interrupt_gracefully, + run_and_wait, + ) + + bodies = [ + run_and_wait("ls", "%1"), + diagnose_failing_pane("%1"), + build_dev_workspace("dev"), + interrupt_gracefully("%1"), + ] + for body in bodies: + for foreign in _FOREIGN_TOOLS: + assert foreign not in body, f"foreign tool {foreign!r} leaked" + # the canonical engine-ops verbs appear + assert "send_input" in run_and_wait("ls", "%1") + assert "split_pane" in build_dev_workspace("dev") + assert "wait_for_output" in interrupt_gracefully("%1") From ee3b2d86462e73e9038393dc5724f469ebf6e475 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:51:54 -0500 Subject: [PATCH 087/223] Mcp(feat[resources]): Add tmux:// hierarchy resources over the engine why: libtmux-mcp exposes the server->session->window->pane tree as MCP resources (a read interface distinct from the list_* tools); the engine-ops server should too, built on its own vocabulary. what: - resources.py: register_resources(mcp, engine, *, is_async) with six tmux:// resources (sessions, session detail, session windows, window detail, pane detail, pane content) over alist_sessions/windows/panes + acapture_pane; rows filtered by session_name/window_index/pane_id - single async body set; a sync server's engine is wrapped once (SyncToAsyncEngine) so there is no sync/async duplication - drop libtmux-mcp's {?socket_name} query var (one socket per engine) - both builders gain include_resources (default True) - tests: offline read returns JSON; live read lists the session + pane content over a real tmux server --- .../experimental/mcp/fastmcp_adapter.py | 10 ++ src/libtmux/experimental/mcp/resources.py | 154 ++++++++++++++++++ tests/experimental/mcp/test_resources.py | 54 ++++++ 3 files changed, 218 insertions(+) create mode 100644 src/libtmux/experimental/mcp/resources.py create mode 100644 tests/experimental/mcp/test_resources.py diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 9e900fd2f..0d9a6e41a 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -654,6 +654,7 @@ def build_server( include_plan_tools: bool = True, include_middleware: bool = True, include_prompts: bool = True, + include_resources: bool = True, safety_level: str | None = None, caller: CallerContext | None = None, ) -> FastMCP: @@ -696,6 +697,10 @@ def build_server( ) if include_plan_tools: register_plan_tools(mcp, engine, is_async=False, registry=registry) + if include_resources: + from libtmux.experimental.mcp.resources import register_resources + + register_resources(mcp, engine, is_async=False) _apply_safety_gate(mcp, level) return mcp @@ -710,6 +715,7 @@ def build_async_server( include_plan_tools: bool = True, include_middleware: bool = True, include_prompts: bool = True, + include_resources: bool = True, safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", @@ -756,6 +762,10 @@ def build_async_server( ) if include_plan_tools: register_plan_tools(mcp, engine, is_async=True, registry=registry) + if include_resources: + from libtmux.experimental.mcp.resources import register_resources + + register_resources(mcp, engine, is_async=True) register_events(mcp, engine, mode=events, source=event_source) _apply_safety_gate(mcp, level) return mcp diff --git a/src/libtmux/experimental/mcp/resources.py b/src/libtmux/experimental/mcp/resources.py new file mode 100644 index 000000000..a83b9e60c --- /dev/null +++ b/src/libtmux/experimental/mcp/resources.py @@ -0,0 +1,154 @@ +"""tmux:// hierarchy resources over the engine. + +Re-expose the server -> session -> window -> pane tree as MCP resources, built on +the curated vocabulary list/capture verbs. The engine binds one socket, so +libtmux-mcp's ``{?socket_name}`` query var is dropped. Every body is ``async`` +and runs over the async vocabulary -- a sync server's engine is wrapped once +(:class:`~.vocabulary._bridge.SyncToAsyncEngine`), so there is no sync/async +duplication. +""" + +from __future__ import annotations + +import json +import typing as t + +from fastmcp.exceptions import ResourceError + +from libtmux.experimental.mcp import vocabulary + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + +_JSON_MIME = "application/json" +_TEXT_MIME = "text/plain" + + +def _json(rows: Iterable[Mapping[str, str]]) -> str: + """Serialize tmux format rows to a pretty JSON array.""" + return json.dumps([dict(row) for row in rows], indent=2) + + +def register_resources( + mcp: FastMCP, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, +) -> None: + """Register the ``tmux://`` hierarchy resources, bound to *engine*.""" + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + + if is_async: + aengine = t.cast("AsyncTmuxEngine", engine) + else: + aengine = t.cast( + "AsyncTmuxEngine", + SyncToAsyncEngine(t.cast("TmuxEngine", engine)), + ) + + @mcp.resource("tmux://sessions", title="All sessions", mime_type=_JSON_MIME) + async def get_sessions() -> str: + """All tmux sessions as a JSON array.""" + return _json((await vocabulary.alist_sessions(aengine)).rows) + + @mcp.resource( + "tmux://sessions/{session_name}", + title="Session detail", + mime_type=_JSON_MIME, + ) + async def get_session(session_name: str) -> str: + """One session plus its windows.""" + sessions = [ + row + for row in (await vocabulary.alist_sessions(aengine)).rows + if row.get("session_name") == session_name + ] + if not sessions: + msg = f"session not found: {session_name}" + raise ResourceError(msg) + windows = [ + row + for row in (await vocabulary.alist_windows(aengine, all_windows=True)).rows + if row.get("session_name") == session_name + ] + result: dict[str, t.Any] = dict(sessions[0]) + result["windows"] = [dict(window) for window in windows] + return json.dumps(result, indent=2) + + @mcp.resource( + "tmux://sessions/{session_name}/windows", + title="Session windows", + mime_type=_JSON_MIME, + ) + async def get_session_windows(session_name: str) -> str: + """Return the windows of a session as a JSON array.""" + windows = [ + row + for row in (await vocabulary.alist_windows(aengine, all_windows=True)).rows + if row.get("session_name") == session_name + ] + return _json(windows) + + @mcp.resource( + "tmux://sessions/{session_name}/windows/{window_index}", + title="Window detail", + mime_type=_JSON_MIME, + ) + async def get_window(session_name: str, window_index: str) -> str: + """One window plus its panes.""" + windows = [ + row + for row in (await vocabulary.alist_windows(aengine, all_windows=True)).rows + if row.get("session_name") == session_name + and row.get("window_index") == window_index + ] + if not windows: + msg = f"window not found: {session_name}:{window_index}" + raise ResourceError(msg) + panes = [ + row + for row in (await vocabulary.alist_panes(aengine, all_panes=True)).rows + if row.get("session_name") == session_name + and row.get("window_index") == window_index + ] + result: dict[str, t.Any] = dict(windows[0]) + result["panes"] = [dict(pane) for pane in panes] + return json.dumps(result, indent=2) + + @mcp.resource("tmux://panes/{pane_id}", title="Pane detail", mime_type=_JSON_MIME) + async def get_pane(pane_id: str) -> str: + """One pane's metadata.""" + panes = [ + row + for row in (await vocabulary.alist_panes(aengine, all_panes=True)).rows + if row.get("pane_id") == pane_id + ] + if not panes: + msg = f"pane not found: {pane_id}" + raise ResourceError(msg) + return json.dumps(dict(panes[0]), indent=2) + + @mcp.resource( + "tmux://panes/{pane_id}/content", + title="Pane content", + mime_type=_TEXT_MIME, + ) + async def get_pane_content(pane_id: str) -> str: + """Return a pane's captured terminal text.""" + capture = await vocabulary.acapture_pane(aengine, pane_id) + return "\n".join(capture.lines) + + # The decorator registers each resource; bind the names so linters do not + # read them as dead code. + _ = ( + get_sessions, + get_session, + get_session_windows, + get_window, + get_pane, + get_pane_content, + ) diff --git a/tests/experimental/mcp/test_resources.py b/tests/experimental/mcp/test_resources.py new file mode 100644 index 000000000..8f5303939 --- /dev/null +++ b/tests/experimental/mcp/test_resources.py @@ -0,0 +1,54 @@ +"""Tests for the tmux:// hierarchy resources on the engine-ops MCP server.""" + +from __future__ import annotations + +import asyncio +import json +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine + +fastmcp = pytest.importorskip("fastmcp") + +from libtmux.experimental.mcp.fastmcp_adapter import build_server # noqa: E402 + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _text(contents: t.Any) -> str: + """Join the text of a read_resource result (robust to content shape).""" + return "".join(getattr(item, "text", "") for item in contents) + + +def test_resources_read_offline_returns_json() -> None: + """The sessions resource is registered and returns a JSON array.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.read_resource("tmux://sessions") + + payload = json.loads(_text(asyncio.run(main()))) + assert isinstance(payload, list) + + +def test_resources_read_live_hierarchy(session: Session) -> None: + """Over a real tmux server, the resources expose the live session + panes.""" + mcp = build_server(SubprocessEngine.for_server(session.server)) + pane = session.active_window.active_pane + assert pane is not None and pane.pane_id is not None + + async def main() -> tuple[str, str]: + async with fastmcp.Client(mcp) as client: + sessions = _text(await client.read_resource("tmux://sessions")) + content = _text( + await client.read_resource(f"tmux://panes/{pane.pane_id}/content"), + ) + return sessions, content + + sessions, _content = asyncio.run(main()) + assert session.session_name is not None + assert session.session_name in sessions # the live session is listed From 603e3603df283525adcc4079d90bf7cc2ef4e22b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:56:38 -0500 Subject: [PATCH 088/223] Mcp(feat[lifespan]): Add engine-probe lifespan (async server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: fail fast when the engine cannot reach tmux at startup (missing binary, broken connection) instead of surfacing it on the first tool call — parity with libtmux-mcp's preflight. what: - _lifespan.py: make_lifespan(engine) runs list-sessions at startup and raises RuntimeError only on an engine-broken outcome (it raises), never on a tmux-side error (returned as a CommandResult, e.g. no server) - build_async_server gains lifespan (default True), passed at FastMCP construction; the sync server stays lifespan-less - tests: broken engine fails the preflight; a tmux-side error is tolerated note: the paste-buffer GC half of libtmux-mcp's lifespan is deferred — engine-ops does not namespace MCP-created buffers, so there is no prefix to GC (a follow-up). --- src/libtmux/experimental/mcp/_lifespan.py | 46 +++++++++++++ .../experimental/mcp/fastmcp_adapter.py | 3 + tests/experimental/mcp/test_lifespan.py | 67 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/libtmux/experimental/mcp/_lifespan.py create mode 100644 tests/experimental/mcp/test_lifespan.py diff --git a/src/libtmux/experimental/mcp/_lifespan.py b/src/libtmux/experimental/mcp/_lifespan.py new file mode 100644 index 000000000..2008fa06e --- /dev/null +++ b/src/libtmux/experimental/mcp/_lifespan.py @@ -0,0 +1,46 @@ +"""Engine-probe lifespan for the async MCP server. + +A startup preflight that fails fast if the engine cannot reach tmux at all +(missing binary, a fundamentally broken connection) -- distinct from a tmux-side +error such as "no server running", which the engine returns as data, not an +exception. Shutdown is a best-effort no-op: engine-ops does not namespace +MCP-created paste buffers, so there is no buffer GC to run (a documented +follow-up). +""" + +from __future__ import annotations + +import contextlib +import typing as t + +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine + + +def make_lifespan( + engine: AsyncTmuxEngine, +) -> Callable[[FastMCP], contextlib.AbstractAsyncContextManager[None]]: + """Return a FastMCP lifespan that probes *engine* at startup. + + The probe runs ``list-sessions`` over *engine* and raises ``RuntimeError`` + only when the engine itself is broken (it raises -- missing binary, lost + connection), never on a tmux-side failure, which comes back as a + :class:`~..engines.base.CommandResult`. + """ + + @contextlib.asynccontextmanager + async def _lifespan(_app: FastMCP) -> AsyncIterator[None]: + try: + await engine.run(CommandRequest.from_args("list-sessions")) + except Exception as error: + msg = f"tmux engine preflight failed: {error}" + raise RuntimeError(msg) from error + yield + + return _lifespan diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 0d9a6e41a..6c448550c 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -716,6 +716,7 @@ def build_async_server( include_middleware: bool = True, include_prompts: bool = True, include_resources: bool = True, + lifespan: bool = True, safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", @@ -734,6 +735,7 @@ def build_async_server( """ from fastmcp import FastMCP + from libtmux.experimental.mcp._lifespan import make_lifespan from libtmux.experimental.mcp.events import _supports_stream, register_events level = _resolve_level(safety_level) @@ -744,6 +746,7 @@ def build_async_server( name=name, instructions=instructions or _instructions(ctx, events_enabled=events_enabled), middleware=_make_middleware(level) if include_middleware else None, + lifespan=make_lifespan(engine) if lifespan else None, ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) diff --git a/tests/experimental/mcp/test_lifespan.py b/tests/experimental/mcp/test_lifespan.py new file mode 100644 index 000000000..95dc7aaf5 --- /dev/null +++ b/tests/experimental/mcp/test_lifespan.py @@ -0,0 +1,67 @@ +"""Tests for the engine-probe lifespan.""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +pytest.importorskip("fastmcp") + + +class _BrokenEngine: + """An engine whose every command raises (a broken connection).""" + + async def run(self, _request: t.Any) -> t.Any: + msg = "connection lost" + raise ConnectionError(msg) + + async def run_batch(self, requests: t.Any) -> t.Any: + return await self.run(requests) + + +class _TmuxErrorEngine: + """An engine that returns a tmux-side failure as data (never raises).""" + + async def run(self, _request: t.Any) -> t.Any: + from libtmux.experimental.engines.base import CommandResult + + return CommandResult( + cmd=("tmux", "list-sessions"), + returncode=1, + stderr=("no server running",), + ) + + async def run_batch(self, requests: t.Any) -> t.Any: + return [await self.run(requests)] + + +def test_lifespan_raises_on_broken_engine() -> None: + """A broken engine (raises) fails the startup preflight loudly.""" + from libtmux.experimental.mcp._lifespan import make_lifespan + + lifespan = make_lifespan(t.cast("t.Any", _BrokenEngine())) + + async def main() -> None: + async with lifespan(t.cast("t.Any", None)): + pass + + with pytest.raises(RuntimeError, match="preflight failed"): + asyncio.run(main()) + + +def test_lifespan_tolerates_tmux_side_error() -> None: + """A tmux-side error (returned as data) does not fail startup.""" + from libtmux.experimental.mcp._lifespan import make_lifespan + + lifespan = make_lifespan(t.cast("t.Any", _TmuxErrorEngine())) + entered = False + + async def main() -> None: + nonlocal entered + async with lifespan(t.cast("t.Any", None)): + entered = True + + asyncio.run(main()) + assert entered From ab449bbc43b20343de9c16c87d61a9814c2b5f4d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:05:11 -0500 Subject: [PATCH 089/223] Workspace(feat[cli]): Add `load` command for .tmuxp.yaml files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the declarative workspace tier had no human entry point — building a workspace meant calling analyze()+build() in Python. Mirror `tmuxp load` so a .tmuxp.yaml launches from the shell. what: - workspace/cli.py: `python -m libtmux.experimental.workspace.cli load ` resolves a workspace file (path / directory -> .tmuxp.*/ bare name under $TMUXP_WORKSPACEDIR), expands ~/$VAR/./ paths relative to the file's dir (the cwd-bound step analyze() deliberately omits), analyzes + builds over a SubprocessEngine, then attaches (switch-client when inside tmux) unless -d; -L/-S socket, -s session-name override - an already-running session is attached, not rebuilt (FileExistsError -> attach), matching tmuxp's behavior - tests: file resolution (path/dir/missing), ./-relative path expansion, arg parsing, and a live detached build whose windows/panes match the file --- src/libtmux/experimental/workspace/cli.py | 299 ++++++++++++++++++ .../contract/test_workspace_cli.py | 98 ++++++ 2 files changed, 397 insertions(+) create mode 100644 src/libtmux/experimental/workspace/cli.py create mode 100644 tests/experimental/contract/test_workspace_cli.py diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py new file mode 100644 index 000000000..7a9f8ef60 --- /dev/null +++ b/src/libtmux/experimental/workspace/cli.py @@ -0,0 +1,299 @@ +"""Command-line entry to launch a tmuxp-style workspace file. + +Run with:: + + uv run python -m libtmux.experimental.workspace.cli load + +A thin shell over the declarative tier: it resolves a workspace file (path, +directory, or a name under the workspace dir), expands ``~`` / ``$VAR`` / ``./`` +paths relative to the file's directory (the part :func:`~.analyzer.analyze` +deliberately leaves to a caller with a cwd), analyzes it into a +:class:`~.ir.Workspace`, builds it over a :class:`~..engines.SubprocessEngine`, +and -- unless ``-d`` -- attaches (or switches the client when already inside +tmux), mirroring ``tmuxp load``. + +Everything here is experimental and outside the versioning policy. +""" + +from __future__ import annotations + +import argparse +import collections.abc +import os +import pathlib +import sys +import typing as t + +from libtmux.experimental.workspace.analyzer import analyze + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.ops.plan import PlanResult + +#: Filenames searched when a directory is given (tmuxp's convention). +_WORKSPACE_FILENAMES = (".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json") +#: Extensions tried when a bare name is given (resolved under the workspace dir). +_WORKSPACE_EXTENSIONS = ("yaml", "yml", "json") + + +def _workspace_dir() -> pathlib.Path: + """Return the directory bare workspace names resolve against (``~/.tmuxp``).""" + return pathlib.Path( + os.environ.get("TMUXP_WORKSPACEDIR", "~/.tmuxp"), + ).expanduser() + + +def _find_workspace_file(arg: str) -> pathlib.Path: + """Resolve *arg* to a concrete workspace file. + + A path to a file is used as-is; a directory is searched for + ``.tmuxp.{yaml,yml,json}``; a bare name (no directory, no extension) is + resolved against the workspace dir (``$TMUXP_WORKSPACEDIR`` or ``~/.tmuxp``). + """ + path = pathlib.Path(arg).expanduser() + if path.is_file(): + return path + if path.is_dir(): + for name in _WORKSPACE_FILENAMES: + candidate = path / name + if candidate.is_file(): + return candidate + msg = f"no .tmuxp.{{yaml,yml,json}} found in {path}" + raise FileNotFoundError(msg) + if os.sep not in arg and "." not in pathlib.Path(arg).name: + workspace_dir = _workspace_dir() + for ext in _WORKSPACE_EXTENSIONS: + candidate = workspace_dir / f"{arg}.{ext}" + if candidate.is_file(): + return candidate + msg = f"workspace file not found: {arg}" + raise FileNotFoundError(msg) + + +def _expandshell(value: str, cwd: str | os.PathLike[str]) -> str: + """Expand ``~`` and ``$VAR``; resolve a ``./``-relative path against *cwd*. + + Mirrors tmuxp's ``expandshell`` + relative-path rule: only ``.``-prefixed + paths are joined to *cwd* (a bare relative path is left for tmux to resolve + against the pane's directory). + + Examples + -------- + >>> _expandshell("/abs/path", "/tmp") + '/abs/path' + >>> _expandshell("./src", "/home/me/proj") + '/home/me/proj/src' + >>> _expandshell("../sibling", "/home/me/proj") + '/home/me/sibling' + """ + raw = os.path.expandvars(value) + if raw in {".", ".."} or raw.startswith(("./", "../")): + # Check the relative prefix on the raw value -- Path() would collapse the + # leading "./" before we could test for it. + return os.path.normpath(pathlib.Path(cwd) / raw) + return str(pathlib.Path(raw).expanduser()) + + +def _expand_env( + environment: collections.abc.Mapping[str, t.Any], +) -> dict[str, str]: + """Expand ``$VAR`` in environment *values* (values, not paths).""" + return { + key: os.path.expandvars(str(value)) for key, value in environment.items() + } + + +def _expand_pane(pane: t.Any, cwd: str | os.PathLike[str]) -> t.Any: + """Expand a pane's ``start_directory`` (mappings only; strings pass through).""" + if isinstance(pane, collections.abc.Mapping) and pane.get("start_directory"): + expanded = dict(pane) + expanded["start_directory"] = _expandshell(pane["start_directory"], cwd) + return expanded + return pane + + +def _expand_window( + window: collections.abc.Mapping[str, t.Any], + cwd: str | os.PathLike[str], +) -> dict[str, t.Any]: + """Expand a window's ``start_directory`` / ``environment`` and its panes.""" + expanded = dict(window) + if window.get("start_directory"): + expanded["start_directory"] = _expandshell(window["start_directory"], cwd) + if window.get("environment"): + expanded["environment"] = _expand_env(window["environment"]) + panes = window.get("panes") + if panes: + expanded["panes"] = [_expand_pane(pane, cwd) for pane in panes] + return expanded + + +def _expand_workspace( + raw: collections.abc.Mapping[str, t.Any], + cwd: str | os.PathLike[str], +) -> dict[str, t.Any]: + """Expand path/var-bearing fields relative to the workspace file's *cwd*. + + The analyzer is intentionally pure (no cwd), so this CLI does the + expansion ``tmuxp load`` does: ``start_directory`` (session/window/pane), + ``before_script``, and ``environment`` values. + """ + expanded = dict(raw) + if raw.get("start_directory"): + expanded["start_directory"] = _expandshell(raw["start_directory"], cwd) + if raw.get("before_script"): + expanded["before_script"] = _expandshell(raw["before_script"], cwd) + if raw.get("environment"): + expanded["environment"] = _expand_env(raw["environment"]) + expanded["windows"] = [ + _expand_window(window, cwd) for window in raw.get("windows", []) or [] + ] + return expanded + + +def _read_workspace(path: pathlib.Path) -> dict[str, t.Any]: + """Parse a YAML/JSON workspace file into a mapping (YAML parses JSON too).""" + import yaml # type: ignore[import-untyped] + + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, collections.abc.Mapping): + msg = f"workspace file {path} must contain a mapping" + raise TypeError(msg) + return dict(data) + + +def _attach(session: t.Any, *, detached: bool) -> None: + """Attach the session, or switch the client when already inside tmux.""" + if detached: + return + if "TMUX" in os.environ: + session.switch_client() + else: + session.attach() + + +def load( + workspace_file: str, + *, + socket_name: str | None = None, + socket_path: str | None = None, + new_session_name: str | None = None, + detached: bool = False, +) -> PlanResult | None: + """Build (and unless *detached*, attach) a workspace file. + + Resolves *workspace_file*, expands its paths, analyzes it, and builds it over + a subprocess engine bound to a :class:`libtmux.Server` on the given socket. An + already-running session of the same name is attached rather than rebuilt + (unless the file's ``on_exists`` opts into ``replace``/``reuse``). + + Returns + ------- + PlanResult or None + The build outcome, or ``None`` when an existing session was attached. + """ + import libtmux + from libtmux.experimental.engines import SubprocessEngine + + path = _find_workspace_file(workspace_file) + raw = _expand_workspace(_read_workspace(path), cwd=path.parent) + if new_session_name: + raw["session_name"] = new_session_name + workspace = analyze(raw) + + server = libtmux.Server(socket_name=socket_name, socket_path=socket_path) + engine = SubprocessEngine.for_server(server) + + existed = server.has_session(workspace.name) + result: PlanResult | None = None + try: + result = workspace.build(engine) + except FileExistsError: + # on_exists="error" (the default) and the session is already running; + # attach to it rather than failing, matching `tmuxp load`. + existed = True + + session = server.sessions.get(session_name=workspace.name, default=None) + if session is None: + msg = f"session {workspace.name!r} was not found after build" + raise RuntimeError(msg) + + verb = "attached existing" if (existed and result is None) else "built" + windows = len(session.windows) + panes = sum(len(window.panes) for window in session.windows) + sys.stderr.write( + f"✓ {verb} session {workspace.name!r} ({windows} windows, {panes} panes)\n", + ) + _attach(session, detached=detached) + return result + + +def _build_parser() -> argparse.ArgumentParser: + """Build the argument parser for the workspace CLI.""" + parser = argparse.ArgumentParser( + prog="python -m libtmux.experimental.workspace.cli", + description="Build and attach an experimental libtmux workspace file.", + ) + sub = parser.add_subparsers(dest="command", required=True) + load_parser = sub.add_parser( + "load", + help="build (and attach) a tmuxp-style workspace file", + description=( + "Load a .tmuxp.{yaml,yml,json} workspace: a file path, a directory " + "(searched for .tmuxp.*), or a bare name under " + "$TMUXP_WORKSPACEDIR (default ~/.tmuxp)." + ), + ) + load_parser.add_argument( + "workspace_file", + metavar="workspace-file", + help="path, directory, or name of a .tmuxp.{yaml,yml,json} file", + ) + load_parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="tmux -L socket name", + ) + load_parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="tmux -S socket path", + ) + load_parser.add_argument( + "-s", + dest="new_session_name", + metavar="session-name", + help="override the workspace's session name", + ) + load_parser.add_argument( + "-d", + "--detached", + action="store_true", + help="build the session without attaching", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> None: + """Run the workspace CLI (the ``python -m ...workspace.cli`` entry). + + Requires a tmux binary on ``PATH``. ``load`` builds the workspace and, unless + ``-d`` is given, attaches it (or switches the client when already inside + tmux). + """ + args = _build_parser().parse_args(argv) + if args.command == "load": + load( + args.workspace_file, + socket_name=args.socket_name, + socket_path=args.socket_path, + new_session_name=args.new_session_name, + detached=args.detached, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/experimental/contract/test_workspace_cli.py b/tests/experimental/contract/test_workspace_cli.py new file mode 100644 index 000000000..6d5c84dde --- /dev/null +++ b/tests/experimental/contract/test_workspace_cli.py @@ -0,0 +1,98 @@ +"""Tests for the workspace CLI (``python -m ...workspace.cli load``).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.workspace import cli + +if t.TYPE_CHECKING: + from pathlib import Path + + +def test_find_workspace_file_direct_path(tmp_path: Path) -> None: + """A direct file path is returned as-is.""" + target = tmp_path / "ws.yaml" + target.write_text("session_name: x\n") + assert cli._find_workspace_file(str(target)) == target + + +def test_find_workspace_file_in_directory(tmp_path: Path) -> None: + """A directory is searched for .tmuxp.{yaml,yml,json}.""" + target = tmp_path / ".tmuxp.yaml" + target.write_text("session_name: x\n") + assert cli._find_workspace_file(str(tmp_path)) == target + + +def test_find_workspace_file_missing_raises(tmp_path: Path) -> None: + """A missing file fails closed.""" + with pytest.raises(FileNotFoundError): + cli._find_workspace_file(str(tmp_path / "nope.yaml")) + + +def test_expand_workspace_resolves_relative_paths(tmp_path: Path) -> None: + """start_directory is expanded relative to the workspace file's directory.""" + raw = { + "session_name": "x", + "start_directory": "./root", + "windows": [ + { + "window_name": "w", + "start_directory": "./win", + "panes": [{"shell_command": ["echo hi"], "start_directory": "./pane"}], + }, + ], + } + expanded = cli._expand_workspace(raw, cwd=tmp_path) + assert expanded["start_directory"] == str(tmp_path / "root") + assert expanded["windows"][0]["start_directory"] == str(tmp_path / "win") + pane = expanded["windows"][0]["panes"][0] + assert pane["start_directory"] == str(tmp_path / "pane") + + +def test_parser_load_arguments() -> None: + """The load subparser captures the workspace file and the flags.""" + args = cli._build_parser().parse_args( + ["load", "ws.yaml", "-d", "-L", "sock", "-s", "newname"], + ) + assert args.command == "load" + assert args.workspace_file == "ws.yaml" + assert args.detached is True + assert args.socket_name == "sock" + assert args.new_session_name == "newname" + + +def test_load_builds_and_reattaches(tmp_path: Path) -> None: + """load() builds a real detached session; a second load attaches it (no rebuild).""" + import libtmux + + socket = "libtmux_wscli_test" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: clitest\n" + f"start_directory: {tmp_path}\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - echo two\n" + " - window_name: logs\n" + " panes:\n" + " - echo log\n", + ) + server = libtmux.Server(socket_name=socket) + try: + result = cli.load(str(tmp_path), socket_name=socket, detached=True) + assert result is not None + assert result.ok + assert server.has_session("clitest") + session = server.sessions.get(session_name="clitest") + assert session is not None + assert [window.window_name for window in session.windows] == ["editor", "logs"] + + # A second load finds the running session and attaches it (no rebuild). + again = cli.load(str(tmp_path), socket_name=socket, detached=True) + assert again is None + finally: + server.kill() From 9cd6d16323efd2a5b77de1de3349d632b518412b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:05:38 -0500 Subject: [PATCH 090/223] Workspace(feat): blank/pane empty-pane parity + cli --dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: real .tmuxp.yaml files use `- blank` / `- pane` / `- ` to mean "an empty pane" (no command) — the analyzer was sending those as literal commands. And launching a file blind is risky; a dry run lets you see the tmux commands first. what: - analyzer: a pane whose sole content is None / "blank" / "pane" / "" (a bare string or a single-element shell_command) is now an empty pane, matching tmuxp's expand_cmd; a blank mixed with real commands is left alone - cli: `load --dry-run` prints the tmux command lines (resolved against the in-memory ConcreteEngine so ids render) with host steps as comments, executing nothing - tests: blank/pane/empty shorthands -> empty panes; dry-run prints the commands (blank pane creates a split but sends no keys) and starts no tmux server --- .../experimental/workspace/analyzer.py | 27 ++++++- src/libtmux/experimental/workspace/cli.py | 73 +++++++++++++++++-- ..._async_control_engine_workspace_builder.py | 28 +++++++ .../contract/test_workspace_cli.py | 29 ++++++++ 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 6d227b3c3..0cb3d9c44 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -93,9 +93,23 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: ) +#: Pane shorthands that mean "an empty pane" (no command sent), per tmuxp. +_BLANK_PANE = frozenset({"", "blank", "pane"}) + + +def _is_blank_command(item: t.Any) -> bool: + """Whether a pane/command shorthand means an empty pane (None/blank/pane).""" + return item is None or (isinstance(item, str) and item.strip() in _BLANK_PANE) + + def _pane(raw: t.Any) -> Pane: - """Normalize one pane config (None / bare string / mapping).""" - if raw is None: + """Normalize one pane config (None / bare string / mapping). + + The bare-string shorthands ``blank`` / ``pane`` (and an empty/``None`` entry) + mean "create an empty pane" -- they are markers, not commands, matching + tmuxp. + """ + if raw is None or (isinstance(raw, str) and raw.strip() in _BLANK_PANE): return Pane() if isinstance(raw, str): return Pane(run=raw) @@ -124,9 +138,14 @@ def _shell_commands(value: t.Any) -> tuple[str | Command, ...]: if value is None: return () if isinstance(value, str): - return (value,) + return () if value.strip() in _BLANK_PANE else (value,) + items = list(t.cast("collections.abc.Sequence[t.Any]", value)) + # A sole blank/pane/None element means "an empty pane" (tmuxp parity); a + # blank mixed with real commands is left alone. + if len(items) == 1 and _is_blank_command(items[0]): + return () out: list[str | Command] = [] - for item in t.cast("collections.abc.Sequence[t.Any]", value): + for item in items: if isinstance(item, str): out.append(item) elif isinstance(item, collections.abc.Mapping): diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py index 7a9f8ef60..c461c976d 100644 --- a/src/libtmux/experimental/workspace/cli.py +++ b/src/libtmux/experimental/workspace/cli.py @@ -30,6 +30,7 @@ from collections.abc import Sequence from libtmux.experimental.ops.plan import PlanResult + from libtmux.experimental.workspace.ir import Workspace #: Filenames searched when a directory is given (tmuxp's convention). _WORKSPACE_FILENAMES = (".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json") @@ -99,9 +100,7 @@ def _expand_env( environment: collections.abc.Mapping[str, t.Any], ) -> dict[str, str]: """Expand ``$VAR`` in environment *values* (values, not paths).""" - return { - key: os.path.expandvars(str(value)) for key, value in environment.items() - } + return {key: os.path.expandvars(str(value)) for key, value in environment.items()} def _expand_pane(pane: t.Any, cwd: str | os.PathLike[str]) -> t.Any: @@ -173,6 +172,50 @@ def _attach(session: t.Any, *, detached: bool) -> None: session.attach() +def _print_dry_run( + workspace: Workspace, + *, + socket_name: str | None, + socket_path: str | None, +) -> None: + """Print the tmux commands a build would run, without touching tmux. + + The plan is resolved against the in-memory ``ConcreteEngine`` (which + fabricates ids) so every line renders fully; host steps (sleep / + before_script / pane-readiness) print as comments in execution order. + """ + import shlex + + from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.workspace.compiler import HostStep, compile_full + + compiled = compile_full(workspace) + outcome = compiled.plan.execute(ConcreteEngine()) + + prefix: list[str] = ["tmux"] + if socket_name: + prefix += ["-L", socket_name] + if socket_path: + prefix += ["-S", socket_path] + + def _emit_host(step: HostStep) -> None: + if step.kind == "sleep": + print(f"# sleep {step.seconds}") + elif step.kind == "script": + where = f" (cwd {step.cwd})" if step.cwd else "" + print(f"# before_script{where}: {step.command}") + elif step.kind == "wait_pane": + print("# wait for the pane's shell to be ready") + + print(f"# build plan for session {workspace.name!r} (dry run, ids fabricated)") + for step in compiled.pre: + _emit_host(step) + for index, result in enumerate(outcome.results): + print(shlex.join([*prefix, *result.argv])) + for step in compiled.host_after.get(index, ()): + _emit_host(step) + + def load( workspace_file: str, *, @@ -180,28 +223,35 @@ def load( socket_path: str | None = None, new_session_name: str | None = None, detached: bool = False, + dry_run: bool = False, ) -> PlanResult | None: """Build (and unless *detached*, attach) a workspace file. Resolves *workspace_file*, expands its paths, analyzes it, and builds it over a subprocess engine bound to a :class:`libtmux.Server` on the given socket. An already-running session of the same name is attached rather than rebuilt - (unless the file's ``on_exists`` opts into ``replace``/``reuse``). + (unless the file's ``on_exists`` opts into ``replace``/``reuse``). With + *dry_run*, the tmux commands are printed and nothing is executed. Returns ------- PlanResult or None - The build outcome, or ``None`` when an existing session was attached. + The build outcome, or ``None`` when an existing session was attached or a + dry run was requested. """ - import libtmux - from libtmux.experimental.engines import SubprocessEngine - path = _find_workspace_file(workspace_file) raw = _expand_workspace(_read_workspace(path), cwd=path.parent) if new_session_name: raw["session_name"] = new_session_name workspace = analyze(raw) + if dry_run: + _print_dry_run(workspace, socket_name=socket_name, socket_path=socket_path) + return None + + import libtmux + from libtmux.experimental.engines import SubprocessEngine + server = libtmux.Server(socket_name=socket_name, socket_path=socket_path) engine = SubprocessEngine.for_server(server) @@ -274,6 +324,12 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="build the session without attaching", ) + load_parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="print the tmux commands that would run, without executing them", + ) return parser @@ -292,6 +348,7 @@ def main(argv: Sequence[str] | None = None) -> None: socket_path=args.socket_path, new_session_name=args.new_session_name, detached=args.detached, + dry_run=args.dry_run, ) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 51142c7dd..1e1716c6f 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -466,6 +466,34 @@ def test_analyze_rejects_unsupported_pane() -> None: analyze({"session_name": "s", "windows": [{"panes": [123]}]}) +def test_analyze_blank_pane_shorthands() -> None: + """Blank / pane / empty shorthands make an empty pane (no command), tmuxp parity.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + "blank", + "pane", + None, + "", + {"shell_command": ["blank"]}, + "echo real", + ], + }, + ], + }, + ) + commands = [pane.commands for pane in ws.windows[0].panes] + assert commands[0] == () # "blank" marker + assert commands[1] == () # "pane" marker + assert commands[2] == () # None + assert commands[3] == () # empty string + assert commands[4] == () # single-element [blank] shell_command + assert [c.cmd for c in commands[5]] == ["echo real"] # a real command is kept + + # --- Compiler: op emission + host-step schedule (offline, no tmux) --- diff --git a/tests/experimental/contract/test_workspace_cli.py b/tests/experimental/contract/test_workspace_cli.py index 6d5c84dde..83dbcee59 100644 --- a/tests/experimental/contract/test_workspace_cli.py +++ b/tests/experimental/contract/test_workspace_cli.py @@ -96,3 +96,32 @@ def test_load_builds_and_reattaches(tmp_path: Path) -> None: assert again is None finally: server.kill() + + +def test_dry_run_prints_commands_without_touching_tmux( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """--dry-run renders the tmux commands (blank pane = no send) and runs nothing.""" + import libtmux + + socket = "libtmux_wscli_dryrun" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: dry\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - blank\n", + ) + result = cli.load(str(tmp_path), socket_name=socket, dry_run=True) + assert result is None + + out = capsys.readouterr().out + assert f"tmux -L {socket} new-session" in out # socket prefix + create + assert "split-window" in out # the blank pane is still created + assert "echo one" in out + # the blank pane sends no command, so exactly one send-keys line is rendered + assert out.count("send-keys") == 1 + # nothing was executed: no tmux server exists on the dry-run socket + assert not libtmux.Server(socket_name=socket).is_alive() From ce87e867358c2559401344e962d3194949d739c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:09:05 -0500 Subject: [PATCH 091/223] Workspace(fix): First window's start_directory for its first pane why: window 0 reuses the session's implicit window/pane, so its first pane inherited the *session* start_directory (-c on new-session) instead of the window's. A per-project tmuxp config (each window cd'd into its repo) opened window 1's first pane in the session root, not the repo. what: - compiler: _creator_start_directory folds the window's (and its first pane's) start_directory into the creator's -c with pane -> window -> session precedence; used for both new-session (window 0) and new-window (windows 2..N). A window without start_directory still falls back to the session's, so existing behavior is unchanged. - test: window 0's start_directory drives new-session -c; fallback to the session dir; a first pane's own start_directory wins --- .../experimental/workspace/compiler.py | 18 ++++++- ..._async_control_engine_workspace_builder.py | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 4d9065901..5a4577118 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -224,6 +224,20 @@ def _creator_environment(window: Window) -> dict[str, str]: return env +def _creator_start_directory(window: Window, ws: Workspace) -> str | None: + """Working directory for a window's *first* (implicit) pane. + + The first pane is created by the window's creator (``new-session`` for + window 0, ``new-window`` for the rest), not a split, so its directory must + ride the creator's ``-c`` with the full pane -> window -> session precedence. + Without this, window 0's first pane would inherit the *session* + ``start_directory`` instead of the window's. + """ + if window.panes and window.panes[0].start_directory: + return window.panes[0].start_directory + return window.start_directory or ws.start_directory + + def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: """Lower a workspace into a Core plan plus its host-step schedule.""" if not ws.windows: @@ -241,7 +255,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: session = plan.add( NewSession( session_name=ws.name, - start_directory=ws.start_directory, + start_directory=_creator_start_directory(ws.windows[0], ws), width=width, height=height, environment=_creator_environment(ws.windows[0]) or None, @@ -275,7 +289,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: NewWindow( target=create_target, name=window.name, - start_directory=window.start_directory or ws.start_directory, + start_directory=_creator_start_directory(window, ws), environment=_creator_environment(window) or None, window_shell=window.window_shell, capture_pane=True, diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 1e1716c6f..3715faa01 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -574,6 +574,58 @@ def test_compile_folds_first_pane_env_into_creator() -> None: assert split.environment == {"SPLIT_ENV": "s"} +def test_compile_first_window_start_directory_drives_new_session() -> None: + """Window 0's start_directory rides new-session -c (its first pane reuses it). + + Window 0 reuses the session's implicit pane, so without folding the window's + directory into ``new-session -c`` the first pane would land in the *session* + start_directory instead of the window's. + """ + ws = Workspace( + name="s", + start_directory="/session", + windows=[ + Window("a", start_directory="/win-a", panes=[Pane(run="x")]), + Window("b", start_directory="/win-b", panes=[Pane(run="y")]), + ], + ) + ops = compile_full(ws).plan.operations + new_session = next(op for op in ops if isinstance(op, NewSession)) + new_window = next(op for op in ops if isinstance(op, NewWindow)) + assert new_session.start_directory == "/win-a" # window 0's dir, not /session + assert new_window.start_directory == "/win-b" + + # a window with no start_directory still falls back to the session's + plain = Workspace( + name="s", + start_directory="/session", + windows=[Window("a", panes=[Pane(run="x")])], + ) + session_op = next( + op for op in compile_full(plain).plan.operations if isinstance(op, NewSession) + ) + assert session_op.start_directory == "/session" + + # a first pane's own start_directory wins for the creator + pane_dir = Workspace( + name="s", + start_directory="/session", + windows=[ + Window( + "a", + start_directory="/win", + panes=[Pane(run="x", start_directory="/pane")], + ), + ], + ) + pane_session_op = next( + op + for op in compile_full(pane_dir).plan.operations + if isinstance(op, NewSession) + ) + assert pane_session_op.start_directory == "/pane" + + def test_compile_threads_window_and_pane_shell() -> None: """window_shell rides new-window; pane.shell (then window_shell) rides split.""" ws = Workspace( From 5143f37111247f76acbb0dd059bf99387808d086 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:13:13 -0500 Subject: [PATCH 092/223] Ops(feat): Per-step host hook and bounded planner why: The declarative runner needs to fold tmux dispatches yet still interleave host-side steps (sleeps, pane-ready waits) between them. These additive Core primitives let any driver reuse the plan trampoline for that without putting host I/O in the sans-I/O core. what: - Add StepReport + _Host sentinel; _drive yields it after each step binds its results (the sched.delayfunc(0) seam), performing no I/O - Add an on_step hook to execute/aexecute; extract _adispatch as the async twin of _dispatch so both pumps share one dispatch seam - Add BoundedPlanner: run an inner planner over the full op list, then split its steps wherever a host-step boundary falls (a marked fold demotes to plain ; chains past the boundary) - Export BoundedPlanner and StepReport from the ops package - Test the hook stream, sync/async parity, and bounded splitting --- src/libtmux/experimental/ops/__init__.py | 5 +- src/libtmux/experimental/ops/plan.py | 83 +++++++++++++++++++++--- src/libtmux/experimental/ops/planner.py | 61 +++++++++++++++++ tests/experimental/ops/test_plan.py | 47 ++++++++++++++ tests/experimental/ops/test_planner.py | 67 ++++++++++++++++++- 5 files changed, 251 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 7950c3edd..28616dd2d 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -106,8 +106,9 @@ ) from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation -from libtmux.experimental.ops.plan import LazyPlan, PlanResult +from libtmux.experimental.ops.plan import LazyPlan, PlanResult, StepReport from libtmux.experimental.ops.planner import ( + BoundedPlanner, FoldingPlanner, MarkedPlanner, Planner, @@ -147,6 +148,7 @@ __all__ = ( "AckResult", + "BoundedPlanner", "BreakPane", "CapturePane", "CapturePaneResult", @@ -237,6 +239,7 @@ "SplitWindowResult", "StartServer", "Status", + "StepReport", "SuspendClient", "SwapPane", "SwapWindow", diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index c97d17598..84d085f7e 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -34,7 +34,7 @@ ) from libtmux.experimental.ops.exc import ForwardCaptureError from libtmux.experimental.ops.execute import arun, run -from libtmux.experimental.ops.planner import Planner, SequentialPlanner +from libtmux.experimental.ops.planner import Planner, PlanStep, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict if t.TYPE_CHECKING: @@ -67,6 +67,38 @@ class _Chain: argv: tuple[str, ...] +@dataclass(frozen=True) +class StepReport: + """One executed :class:`~.planner.PlanStep`, reported to a per-step callback. + + Passed to the ``on_step`` hook of :meth:`LazyPlan.execute` / + :meth:`LazyPlan.aexecute` after each step's results bind, letting a caller + interleave host-side work (e.g. the workspace runner's sleeps and pane-ready + waits) *between* dispatches without forking the resolution core. + + Parameters + ---------- + step : PlanStep + The dispatch unit that just ran. + results : tuple[Result, ...] + The step's per-op results, in ``step.indices`` order. + bindings : dict[int | tuple[int, str], str] + The live binding map (same reference the driver mutates), so the callback + can resolve a :class:`~._types.SlotRef` against already-captured ids. + """ + + step: PlanStep + results: tuple[Result, ...] + bindings: dict[int | tuple[int, str], str] + + +@dataclass(frozen=True) +class _Host: + """Drive request: fire the per-step host hook; the driver returns ``None``.""" + + report: StepReport + + def _target_from_id(value: str) -> Target: """Map a captured concrete id back to its typed target.""" if value.startswith("%"): @@ -247,12 +279,15 @@ def _drive( self, version: str | None, planner: Planner, - ) -> Generator[_Single | _Chain, t.Any, PlanResult]: + ) -> Generator[_Single | _Chain | _Host, t.Any, PlanResult]: """Sans-I/O resolution core driven by a :class:`~.planner.Planner`. Yields a :class:`_Single` (driver runs one op, returns its - :class:`~.results.Result`) or a :class:`_Chain` (driver returns the - merged :class:`~..engines.base.CommandResult`, attributed per op here). + :class:`~.results.Result`), a :class:`_Chain` (driver returns the merged + :class:`~..engines.base.CommandResult`, attributed per op here), or a + :class:`_Host` once per step *after* its results bind (driver fires the + ``on_step`` hook, returns ``None``). The generator performs no host I/O + itself -- the host hook is the single colored leaf the drivers fork on. The sync and async drivers differ only in ``run`` vs ``await arun`` and ``engine.run`` vs ``await engine.run``. """ @@ -292,6 +327,8 @@ def _drive( results.update( zip(step.indices, attribute(group, merged, version), strict=True), ) + ordered_step = tuple(results[i] for i in step.indices) + yield _Host(StepReport(step, ordered_step, bindings)) ordered = tuple(results[slot] for slot in range(len(self._operations))) return PlanResult(ordered, bindings) @@ -301,6 +338,7 @@ def execute( *, version: str | None = None, planner: Planner | None = None, + on_step: t.Callable[[StepReport], None] | None = None, ) -> PlanResult: """Resolve and execute the plan synchronously. @@ -309,12 +347,21 @@ def execute( :class:`~.planner.FoldingPlanner` or :class:`~.planner.MarkedPlanner` to fold dispatches -- the :class:`PlanResult` is identical, only the dispatch count changes. + + *on_step* is called with a :class:`StepReport` after each step's results + bind, so a caller can interleave host-side work between dispatches; it is + a no-op trampoline hop when ``None``. """ gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: - request = gen.send(self._dispatch(request, engine, version)) + if isinstance(request, _Host): + if on_step is not None: + on_step(request.report) + request = gen.send(None) + else: + request = gen.send(self._dispatch(request, engine, version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) @@ -335,16 +382,32 @@ async def aexecute( *, version: str | None = None, planner: Planner | None = None, + on_step: t.Callable[[StepReport], t.Awaitable[None]] | None = None, ) -> PlanResult: - """Resolve and execute the plan asynchronously (same resolution core).""" + """Resolve and execute the plan asynchronously (same resolution core). + + Mirrors :meth:`execute`; *on_step* is awaited per step. + """ gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: - if isinstance(request, _Chain): - raw = await engine.run(CommandRequest.from_args(*request.argv)) - request = gen.send(raw) + if isinstance(request, _Host): + if on_step is not None: + await on_step(request.report) + request = gen.send(None) else: - request = gen.send(await arun(request.op, engine, version=version)) + request = gen.send(await self._adispatch(request, engine, version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) + + async def _adispatch( + self, + request: _Single | _Chain, + engine: AsyncTmuxEngine, + version: str | None, + ) -> t.Any: + """Run one drive request asynchronously (async twin of :meth:`_dispatch`).""" + if isinstance(request, _Chain): + return await engine.run(CommandRequest.from_args(*request.argv)) + return await arun(request.op, engine, version=version) diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py index 53f5f23aa..393f1f8a9 100644 --- a/src/libtmux/experimental/ops/planner.py +++ b/src/libtmux/experimental/ops/planner.py @@ -141,6 +141,67 @@ def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: return steps +def _split_at_boundaries( + step: PlanStep, + boundaries: frozenset[int], +) -> list[PlanStep]: + """Break *step* wherever a boundary falls between two of its indices. + + A boundary at index ``i`` means a host step runs after op ``i``, so no fold + may span ``i -> i+1``. Splitting only ever breaks a step into contiguous + sub-runs (never merges), so it cannot change the result -- only the dispatch + grouping. A ``marked`` step keeps ``marked=True`` on its first sub-run iff the + creator still keeps at least one decorate; later sub-runs become plain + ``;``-chains that resolve the creator's now-bound id instead of ``{marked}``. + """ + indices = step.indices + cuts = [k + 1 for k in range(len(indices) - 1) if indices[k] in boundaries] + if not cuts: + return [step] + starts, ends = [0, *cuts], [*cuts, len(indices)] + runs = [indices[lo:hi] for lo, hi in zip(starts, ends, strict=True)] + return [ + PlanStep(run, marked=step.marked and pos == 0 and len(run) > 1) + for pos, run in enumerate(runs) + ] + + +@dataclass(frozen=True) +class BoundedPlanner: + """Wrap a planner so no fold crosses a host-step boundary. + + *boundaries* are operation indices after which a host step runs (for the + workspace runner, exactly ``frozenset(compiled.host_after)``). The *inner* + planner runs over the full operation list -- so its global + :class:`~._types.SlotRef` matching is unaffected -- and every resulting + :class:`PlanStep` is then split at any boundary it spans. + """ + + inner: Planner + boundaries: frozenset[int] + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Plan with *inner*, then split each step at host-step boundaries. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> ops = [ + ... SendKeys(target=PaneId("%1"), keys="a"), + ... SendKeys(target=PaneId("%1"), keys="b"), + ... ] + >>> BoundedPlanner(FoldingPlanner(), frozenset({0})).plan(ops) + [PlanStep(indices=(0,), marked=False), PlanStep(indices=(1,), marked=False)] + >>> BoundedPlanner(FoldingPlanner(), frozenset()).plan(ops) + [PlanStep(indices=(0, 1), marked=False)] + """ + steps: list[PlanStep] = [] + for step in self.inner.plan(operations): + steps.extend(_split_at_boundaries(step, self.boundaries)) + return steps + + def _marked_decorates( operations: Sequence[Operation[t.Any]], index: int, diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 22caaf3a8..0737c7961 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -15,7 +15,9 @@ MarkedPlanner, MovePane, SendKeys, + SequentialPlanner, SplitWindow, + StepReport, SwapPane, ) from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId @@ -122,6 +124,51 @@ def test_plan_aexecute_matches_execute() -> None: assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") +def test_execute_on_step_reports_each_step() -> None: + """on_step fires once per dispatched step, carrying its per-op results + ids.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + reports: list[StepReport] = [] + outcome = plan.execute( + ConcreteEngine(), + planner=SequentialPlanner(), + on_step=reports.append, + ) + + # one report per op (sequential), in dispatch order + assert [report.step.indices for report in reports] == [(0,), (1,)] + # the creator's report already sees its freshly-bound pane id + assert reports[0].bindings == {0: "%1"} + assert reports[0].results[0].created_id == "%1" + # the decorate report carries the resolved send-keys argv + assert reports[1].results[0].argv == ("send-keys", "-t", "%1", "vim", "Enter") + # the reported results are the same objects the PlanResult collects + assert tuple(report.results[0] for report in reports) == outcome.results + + +def test_aexecute_on_step_matches_execute() -> None: + """The async hook fires identically to the sync one (one report per step).""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + sync_steps: list[tuple[int, ...]] = [] + plan.execute( + ConcreteEngine(), + on_step=lambda report: sync_steps.append(report.step.indices), + ) + + async_steps: list[tuple[int, ...]] = [] + + async def collect(report: StepReport) -> None: + async_steps.append(report.step.indices) + + asyncio.run(plan.aexecute(AsyncConcreteEngine(), on_step=collect)) + assert async_steps == sync_steps == [(0,), (1,)] + + def test_plan_serialization_round_trip() -> None: """A plan (including its SlotRef targets) survives a list round-trip.""" plan = LazyPlan() diff --git a/tests/experimental/ops/test_planner.py b/tests/experimental/ops/test_planner.py index 8595982a2..cc6261e69 100644 --- a/tests/experimental/ops/test_planner.py +++ b/tests/experimental/ops/test_planner.py @@ -11,19 +11,22 @@ import pytest from libtmux.experimental.ops import ( + BoundedPlanner, FoldingPlanner, LazyPlan, MarkedPlanner, + PlanStep, SendKeys, SequentialPlanner, SplitWindow, ) -from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId if t.TYPE_CHECKING: from collections.abc import Sequence from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.planner import Planner from libtmux.session import Session @@ -125,6 +128,68 @@ def test_marked_falls_back_without_pattern() -> None: assert len(engine.calls) == 1 # folded as a plain ; chain +def _split_decorate_plan() -> list[Operation[t.Any]]: + """Return the {marked}-foldable shape: split @1, then two pane decorates.""" + return [ + SplitWindow(target=WindowId("@1")), + SendKeys(target=SlotRef(0), keys="a", enter=True), + SendKeys(target=SlotRef(0), keys="b", enter=True), + ] + + +def test_bounded_planner_no_boundaries_is_identity() -> None: + """With no boundaries, BoundedPlanner reproduces the inner planner exactly.""" + ops = _split_decorate_plan() + inner = MarkedPlanner() + assert BoundedPlanner(inner, frozenset()).plan(ops) == inner.plan(ops) + + +def test_bounded_planner_splits_chain_at_boundary() -> None: + """A boundary breaks a folded chain between the two ops it separates.""" + ops = [ + SendKeys(target=PaneId("%1"), keys="a"), + SendKeys(target=PaneId("%1"), keys="b"), + SendKeys(target=PaneId("%1"), keys="c"), + ] + steps = BoundedPlanner(FoldingPlanner(), frozenset({1})).plan(ops) + assert steps == [PlanStep((0, 1)), PlanStep((2,))] + + +def test_bounded_planner_demotes_marked_at_creator_boundary() -> None: + """A host step after the creator forbids {marked}; the creator dispatches alone.""" + ops = _split_decorate_plan() + steps = BoundedPlanner(MarkedPlanner(), frozenset({0})).plan(ops) + # creator alone, then the decorates as a plain ; chain -- no marked fold spans + # the boundary (the pane id is bound before the host step runs). + assert steps == [PlanStep((0,)), PlanStep((1, 2))] + assert not any(step.marked for step in steps) + + +def test_bounded_planner_keeps_marked_first_run_between_decorates() -> None: + """A boundary between decorates keeps creator+first marked; the rest plain.""" + ops = _split_decorate_plan() + steps = BoundedPlanner(MarkedPlanner(), frozenset({1})).plan(ops) + assert steps == [PlanStep((0, 1), marked=True), PlanStep((2,))] + + +def test_bounded_planner_preserves_result() -> None: + """Bounding a planner changes only dispatch grouping, never the result.""" + plan = _build_plan() + plain = plan.execute(_CountingEngine(), planner=MarkedPlanner()) + bounded = plan.execute( + _CountingEngine(), + planner=BoundedPlanner(MarkedPlanner(), frozenset({0})), + ) + assert [r.argv for r in plain.results] == [r.argv for r in bounded.results] + assert plain.bindings == bounded.bindings + # the boundary forced an extra dispatch without changing the outcome + plain_calls = _CountingEngine() + bounded_calls = _CountingEngine() + plan.execute(plain_calls, planner=MarkedPlanner()) + plan.execute(bounded_calls, planner=BoundedPlanner(MarkedPlanner(), frozenset({0}))) + assert len(bounded_calls.calls) > len(plain_calls.calls) + + def test_marked_fold_live(session: Session) -> None: """The {marked} fold creates and decorates a real pane in one dispatch.""" from libtmux.experimental.engines import SubprocessEngine From a00048b953fbbbdb7d1751dd8b4dcf90cf87ee66 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:16:43 -0500 Subject: [PATCH 093/223] Workspace(feat): Fold build dispatches by default why: A declarative build paid one tmux dispatch per operation because the runner forked its own per-op loop to interleave host steps, bypassing the Core planner. A multi-pane window now renders in a few round-trips instead of dozens, with the same result. what: - Drive build_workspace/abuild_workspace through LazyPlan.execute with BoundedPlanner(MarkedPlanner, frozenset(host_after)) and an on_step hook that replays each index's host steps and build events, deleting the hand-rolled per-op loop - Default the build to folding; add planner= to the runner functions and Workspace.build/abuild so a caller can override (e.g. SequentialPlanner for one legible tmux call per op) - host_after keys are the fold boundaries, so sleeps, the wait_pane anti-race, and before_script keep a fold from ever crossing a pause; the PlanResult is identical, only the dispatch count drops - Add folding contract tests (dispatch reduction, planner equivalence, boundary rules, live subprocess) and a CHANGES deliverable --- CHANGES | 19 +++ src/libtmux/experimental/workspace/ir.py | 11 +- src/libtmux/experimental/workspace/runner.py | 112 ++++++++----- .../contract/test_workspace_folding.py | 148 ++++++++++++++++++ 4 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 tests/experimental/contract/test_workspace_folding.py diff --git a/CHANGES b/CHANGES index 61546b1c7..76aac6b79 100644 --- a/CHANGES +++ b/CHANGES @@ -84,6 +84,25 @@ environment-setup plumbing, so each example leads with the constructor call it demonstrates instead of the socket-path and `$TMUX` boilerplate needed to run it. +#### Declarative workspace builds fold to a few tmux calls (#690) + +A {class}`~libtmux.experimental.workspace.ir.Workspace` declares a session as a +tree of windows and panes and lowers to a Core +{class}`~libtmux.experimental.ops.plan.LazyPlan`, so a tmuxp-style spec can be +analyzed, inspected, and built over any engine. +{meth}`~libtmux.experimental.workspace.ir.Workspace.build` and its async twin +{meth}`~libtmux.experimental.workspace.ir.Workspace.abuild` fold the build's +dispatches by default: a multi-pane window collapses from one tmux call per +operation into a handful of ``;``-chained and ``{marked}`` dispatches, so a +session renders in a few round-trips instead of dozens. + +The resulting {class}`~libtmux.experimental.ops.plan.PlanResult` is identical to +an unfolded build -- only the dispatch count changes -- because host-side steps +(per-command sleeps, the ``wait_pane`` anti-race, ``before_script``) stay hard +fold boundaries that a fold never crosses. Pass a +{class}`~libtmux.experimental.ops.planner.SequentialPlanner` to ``build`` for one +legible tmux call per operation when debugging. + ## libtmux 0.62.0 (2026-07-12) libtmux 0.62.0 teaches libtmux objects to locate themselves and to resolve diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 4b16a8125..552e2cca3 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -37,6 +37,7 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops.plan import LazyPlan, PlanResult + from libtmux.experimental.ops.planner import Planner from libtmux.experimental.workspace.events import BuildEvent @@ -336,13 +337,15 @@ def build( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute this workspace synchronously over *engine*. Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check (e.g. against the stateless ``ConcreteEngine``, which has no real sessions to detect). Pass *on_event* to observe the structural build - stream (see :mod:`~.events`). + stream (see :mod:`~.events`). The build folds dispatches by default; pass + *planner* (e.g. :class:`~..ops.planner.SequentialPlanner`) to override. """ from libtmux.experimental.workspace.runner import build_workspace @@ -352,6 +355,7 @@ def build( version=version, preflight=preflight, on_event=on_event, + planner=planner, ) async def abuild( @@ -361,10 +365,12 @@ async def abuild( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute this workspace asynchronously over *engine*. - *on_event* is awaited for each build event (see :mod:`~.events`). + *on_event* is awaited for each build event (see :mod:`~.events`). Folds by + default; pass *planner* to override. """ from libtmux.experimental.workspace.runner import abuild_workspace @@ -374,4 +380,5 @@ async def abuild( version=version, preflight=preflight, on_event=on_event, + planner=planner, ) diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index 5513259e0..2a73d1222 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -1,11 +1,16 @@ """Execute a compiled workspace over any engine, sync or async. -The runner is the Declarative tier's *bound* layer. It keeps the Core operation -spine pure: it drives the compiled plan one operation at a time (reusing Core's -:func:`~libtmux.experimental.ops.plan._resolve` forward-ref resolution) and -interleaves host-side steps (sleep / before_script) *between* operations rather -than weaving them into Core's ``_drive`` generator. Idempotent replace is handled -*around* the build via a ``has-session`` pre-check. +The runner is the Declarative tier's *bound* layer. It drives the compiled plan +through Core's :meth:`~libtmux.experimental.ops.plan.LazyPlan.execute` so the +build reuses the same sans-I/O resolution trampoline as any other plan -- and so +folds dispatches via a :class:`~..ops.planner.Planner`. Host-side steps (sleep / +before_script / pane-readiness waits) must run *between* tmux dispatches, so the +compiler records them as a separate schedule keyed by operation index +(:attr:`~..compiler.Compiled.host_after`). The runner turns those keys into fold +boundaries via a :class:`~..ops.planner.BoundedPlanner` (no fold may cross a host +step) and replays each index's host steps from the ``on_step`` hook +:meth:`~..ops.plan.LazyPlan.execute` fires after every step binds. Idempotent +replace is handled *around* the build via a ``has-session`` pre-check. The same compiled plan runs identically through any engine and through either the sync (:func:`build_workspace`) or async (:func:`abuild_workspace`) driver -- the @@ -30,7 +35,8 @@ run, ) from libtmux.experimental.ops._types import NameRef -from libtmux.experimental.ops.plan import PlanResult, _resolve +from libtmux.experimental.ops.plan import PlanResult, StepReport, _resolve +from libtmux.experimental.ops.planner import BoundedPlanner, MarkedPlanner from libtmux.experimental.workspace.compiler import compile_full from libtmux.experimental.workspace.events import WorkspaceBuilt, events_for @@ -38,7 +44,7 @@ from collections.abc import Awaitable, Callable from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine - from libtmux.experimental.ops.results import Result + from libtmux.experimental.ops.planner import Planner from libtmux.experimental.workspace.compiler import HostStep from libtmux.experimental.workspace.events import BuildEvent from libtmux.experimental.workspace.ir import Workspace @@ -134,12 +140,20 @@ def build_workspace( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute *ws* synchronously over *engine*. Pass *on_event* to observe the structural build stream (session -> windows -> panes -> built) as each operation binds its id. + The build folds dispatches by default (a :class:`~..ops.planner.MarkedPlanner` + wrapped so no fold crosses a host step), so a multi-pane window costs a few + tmux calls instead of one per op. Pass *planner* to override -- e.g. + :class:`~..ops.planner.SequentialPlanner` for one legible call per op. The + :class:`~..ops.plan.PlanResult` is identical either way; only the dispatch + count changes. + Examples -------- >>> from libtmux.experimental.engines import ConcreteEngine @@ -151,25 +165,30 @@ def build_workspace( if preflight and _preflight_sync(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - bindings: dict[int | tuple[int, str], str] = {} - results: list[Result] = [] + ops = compiled.plan.operations for step in compiled.pre: - _run_host_sync(step, engine, bindings, version) - for index, op in enumerate(compiled.plan.operations): - result = run(_resolve(op, bindings), engine, version=version) - results.append(result) - if result.created_id is not None: - bindings[index] = result.created_id - for part, sub in result.created_subids.items(): - bindings[index, part] = sub - if on_event is not None: - for event in events_for(op, result): - on_event(event) - for step in compiled.host_after.get(index, ()): - _run_host_sync(step, engine, bindings, version) + _run_host_sync(step, engine, {}, version) + + def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + on_event(event) + for host_step in compiled.host_after.get(index, ()): + _run_host_sync(host_step, engine, report.bindings, version) + + outcome = compiled.plan.execute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) if on_event is not None: - on_event(WorkspaceBuilt(bindings.get(0, ""))) - return PlanResult(tuple(results), bindings) + on_event(WorkspaceBuilt(outcome.bindings.get(0, ""))) + return outcome async def abuild_workspace( @@ -179,31 +198,38 @@ async def abuild_workspace( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute *ws* asynchronously over *engine* (same resolution). *on_event* is awaited for each build event, so an async observer can stream - the structural progress (e.g. through a fastmcp Context). + the structural progress (e.g. through a fastmcp Context). Folds by default; + see :func:`build_workspace` for the *planner* knob. """ if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - bindings: dict[int | tuple[int, str], str] = {} - results: list[Result] = [] + ops = compiled.plan.operations for step in compiled.pre: - await _run_host_async(step, engine, bindings, version) - for index, op in enumerate(compiled.plan.operations): - result = await arun(_resolve(op, bindings), engine, version=version) - results.append(result) - if result.created_id is not None: - bindings[index] = result.created_id - for part, sub in result.created_subids.items(): - bindings[index, part] = sub - if on_event is not None: - for event in events_for(op, result): - await on_event(event) - for step in compiled.host_after.get(index, ()): - await _run_host_async(step, engine, bindings, version) + await _run_host_async(step, engine, {}, version) + + async def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + await on_event(event) + for host_step in compiled.host_after.get(index, ()): + await _run_host_async(host_step, engine, report.bindings, version) + + outcome = await compiled.plan.aexecute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) if on_event is not None: - await on_event(WorkspaceBuilt(bindings.get(0, ""))) - return PlanResult(tuple(results), bindings) + await on_event(WorkspaceBuilt(outcome.bindings.get(0, ""))) + return outcome diff --git a/tests/experimental/contract/test_workspace_folding.py b/tests/experimental/contract/test_workspace_folding.py new file mode 100644 index 000000000..78ca64b47 --- /dev/null +++ b/tests/experimental/contract/test_workspace_folding.py @@ -0,0 +1,148 @@ +"""The workspace build folds dispatches through the Core planner. + +A declarative build drives Core's ``LazyPlan.execute`` with a +:class:`~libtmux.experimental.ops.planner.BoundedPlanner`, so a multi-pane window +collapses to a few tmux calls instead of one per op -- while host steps (sleeps, +pane-ready waits) stay hard fold boundaries. These tests pin the dispatch-count +reduction, the planner-equivalence (same ``PlanResult``), and the boundary rules, +offline and live. +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.ops import SequentialPlanner +from libtmux.experimental.workspace import Command, Pane, Window, Workspace + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, TmuxEngine + from libtmux.session import Session + + +@dataclasses.dataclass +class _RecordingEngine: + """Record every dispatch's argv; answer a wait_pane cursor as ready. + + A first-class engine arm (not a monkeypatch): it forwards to a real inner + engine but reports a non-origin cursor for ``display-message`` so the + runner's pane-readiness poll returns on the first try, keeping the tests + fast. + """ + + inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and forward (faking a ready cursor for waits).""" + self.calls.append(request.args) + if "display-message" in request.args: + return CommandResult(cmd=("tmux", *request.args), stdout=("1,1",)) + return self.inner.run(request) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def _spec(*, wait_pane: bool = False) -> Workspace: + """Return a 2-window workspace; the editor window has three command panes.""" + return Workspace( + name="fold", + start_directory="/tmp", + windows=[ + Window( + "editor", + panes=[ + Pane(run="echo a"), + Pane(run=["echo b", "echo c"]), + Pane(run="echo d"), + ], + ), + Window("logs", panes=[Pane(run="echo log")]), + ], + wait_pane=wait_pane, + ) + + +def test_build_folds_by_default() -> None: + """A build folds dispatches without the caller choosing a planner.""" + default = _RecordingEngine() + _spec().build(default, preflight=False) + sequential = _RecordingEngine() + _spec().build(sequential, preflight=False, planner=SequentialPlanner()) + + assert len(default.calls) < len(sequential.calls) + assert any(";" in argv for argv in default.calls) # at least one folded chain + + +def test_build_planner_equivalence() -> None: + """The default (folding) build yields the same PlanResult as the sequential one.""" + folded = _spec().build(ConcreteEngine(), preflight=False) + sequential = _spec().build( + ConcreteEngine(), + preflight=False, + planner=SequentialPlanner(), + ) + + assert [r.argv for r in folded.results] == [r.argv for r in sequential.results] + assert folded.bindings == sequential.bindings + + +def test_build_wait_pane_never_folds_create_into_send() -> None: + """wait_pane keeps the split a fold boundary: no dispatch carries split+send.""" + engine = _RecordingEngine() + _spec(wait_pane=True).build(engine, preflight=False) + + crossed = [ + argv for argv in engine.calls if "split-window" in argv and "send-keys" in argv + ] + assert not crossed + + +def test_build_sleep_after_forces_boundary() -> None: + """A sleep between two sends keeps them in separate dispatches.""" + ws = Workspace( + name="s", + windows=[ + Window("w", panes=[Pane(run=[Command("a", sleep_after=0.0), "b"])]), + ], + ) + engine = _RecordingEngine() + ws.build(engine, preflight=False) + + folded_both = [argv for argv in engine.calls if argv.count("send-keys") == 2] + assert not folded_both + + +def test_build_folds_live_subprocess(session: Session) -> None: + """A folded build creates the real structure with at least one ; chain.""" + server = session.server + engine = _RecordingEngine(SubprocessEngine.for_server(server)) + spec = Workspace( + name="fold-live", + start_directory="/tmp", + windows=[ + Window( + "editor", + panes=[Pane(run="echo a"), Pane(run="echo b"), Pane(run="echo c")], + ), + Window("logs", panes=[Pane(run="echo log")]), + ], + ) + + result = spec.build(engine) + + assert result.ok + built = server.sessions.get(session_name="fold-live") + assert built is not None + assert [w.window_name for w in built.windows] == ["editor", "logs"] + assert [len(w.panes) for w in built.windows] == [3, 1] + # the build folded: at least one ; chain, fewer dispatches than operations + assert any(";" in argv for argv in engine.calls) + assert len(engine.calls) < len(spec.compile().operations) From 5518844b164edc389c347f344e6e9befdbe9f21b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:23:18 -0500 Subject: [PATCH 094/223] Workspace(feat): Fold --dry-run output why: The dry run rendered the unfolded sequential plan, but the build folds by default -- so the preview misrepresented the dispatches that would actually run (one tmux line per op instead of the ; chains). what: - Drive the dry run through the same BoundedPlanner(MarkedPlanner) the build uses, via a recording engine, so the printed lines are the real folded dispatches; a standalone ; renders as \; (copy-pasteable) and the header reports the dispatch count and shape - Add --no-fold to load (and a fold= param) that controls BOTH the dry run rendering and the real build planner, keeping them consistent - Cover the folded/{marked} dry run, --no-fold, and flag parsing --- src/libtmux/experimental/workspace/cli.py | 100 +++++++++++++++--- .../contract/test_workspace_cli.py | 54 ++++++++++ 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py index c461c976d..4489d4d10 100644 --- a/src/libtmux/experimental/workspace/cli.py +++ b/src/libtmux/experimental/workspace/cli.py @@ -29,7 +29,12 @@ if t.TYPE_CHECKING: from collections.abc import Sequence - from libtmux.experimental.ops.plan import PlanResult + from libtmux.experimental.engines.base import ( + CommandRequest, + CommandResult, + TmuxEngine, + ) + from libtmux.experimental.ops.plan import PlanResult, StepReport from libtmux.experimental.workspace.ir import Workspace #: Filenames searched when a directory is given (tmuxp's convention). @@ -172,32 +177,77 @@ def _attach(session: t.Any, *, detached: bool) -> None: session.attach() +class _RecordingEngine: + """Wrap an engine, capturing every dispatched argv for the dry run. + + Each recorded entry is one tmux dispatch -- a folded ``;`` chain renders as a + single argv with bare ``;`` separators, exactly as the real build sends it. + """ + + def __init__(self, inner: TmuxEngine) -> None: + self.inner = inner + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv, then forward to the wrapped engine.""" + self.calls.append(request.args) + return self.inner.run(request) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Forward each request in order, recording as it goes.""" + return [self.run(req) for req in requests] + + def _print_dry_run( workspace: Workspace, *, socket_name: str | None, socket_path: str | None, + fold: bool = True, ) -> None: - """Print the tmux commands a build would run, without touching tmux. + r"""Print the tmux commands a build would run, without touching tmux. The plan is resolved against the in-memory ``ConcreteEngine`` (which - fabricates ids) so every line renders fully; host steps (sleep / - before_script / pane-readiness) print as comments in execution order. + fabricates ids) through the *same* planner the real build uses, so the + printed lines are the folded ``;`` dispatches that would actually run -- not + an unfolded op-per-line view. Pass ``fold=False`` for one tmux call per + operation. Host steps (sleep / before_script / pane-readiness) print as + comments in execution order, and a standalone ``;`` renders as ``\;`` so a + line stays copy-pasteable into a shell. """ import shlex from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.ops import ( + BoundedPlanner, + MarkedPlanner, + SequentialPlanner, + ) from libtmux.experimental.workspace.compiler import HostStep, compile_full compiled = compile_full(workspace) - outcome = compiled.plan.execute(ConcreteEngine()) - prefix: list[str] = ["tmux"] if socket_name: prefix += ["-L", socket_name] if socket_path: prefix += ["-S", socket_path] + planner = ( + BoundedPlanner(MarkedPlanner(), frozenset(compiled.host_after)) + if fold + else SequentialPlanner() + ) + engine = _RecordingEngine(ConcreteEngine()) + hosts_per_dispatch: list[tuple[HostStep, ...]] = [] + + def on_step(report: StepReport) -> None: + steps: list[HostStep] = [] + for index in report.step.indices: + steps.extend(compiled.host_after.get(index, ())) + hosts_per_dispatch.append(tuple(steps)) + + compiled.plan.execute(engine, planner=planner, on_step=on_step) + def _emit_host(step: HostStep) -> None: if step.kind == "sleep": print(f"# sleep {step.seconds}") @@ -207,12 +257,21 @@ def _emit_host(step: HostStep) -> None: elif step.kind == "wait_pane": print("# wait for the pane's shell to be ready") - print(f"# build plan for session {workspace.name!r} (dry run, ids fabricated)") + def _render(argv: tuple[str, ...]) -> str: + return " ".join( + "\\;" if token == ";" else shlex.quote(token) for token in (*prefix, *argv) + ) + + shape = "folded" if fold else "sequential" + print( + f"# build plan for session {workspace.name!r} " + f"({len(engine.calls)} dispatches, {shape}, ids fabricated)", + ) for step in compiled.pre: _emit_host(step) - for index, result in enumerate(outcome.results): - print(shlex.join([*prefix, *result.argv])) - for step in compiled.host_after.get(index, ()): + for argv, hosts in zip(engine.calls, hosts_per_dispatch, strict=True): + print(_render(argv)) + for step in hosts: _emit_host(step) @@ -224,6 +283,7 @@ def load( new_session_name: str | None = None, detached: bool = False, dry_run: bool = False, + fold: bool = True, ) -> PlanResult | None: """Build (and unless *detached*, attach) a workspace file. @@ -233,6 +293,9 @@ def load( (unless the file's ``on_exists`` opts into ``replace``/``reuse``). With *dry_run*, the tmux commands are printed and nothing is executed. + The build folds tmux dispatches by default (``fold=True``); ``fold=False`` + issues one tmux call per operation, for both the dry run and the real build. + Returns ------- PlanResult or None @@ -246,11 +309,17 @@ def load( workspace = analyze(raw) if dry_run: - _print_dry_run(workspace, socket_name=socket_name, socket_path=socket_path) + _print_dry_run( + workspace, + socket_name=socket_name, + socket_path=socket_path, + fold=fold, + ) return None import libtmux from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import SequentialPlanner server = libtmux.Server(socket_name=socket_name, socket_path=socket_path) engine = SubprocessEngine.for_server(server) @@ -258,7 +327,7 @@ def load( existed = server.has_session(workspace.name) result: PlanResult | None = None try: - result = workspace.build(engine) + result = workspace.build(engine, planner=None if fold else SequentialPlanner()) except FileExistsError: # on_exists="error" (the default) and the session is already running; # attach to it rather than failing, matching `tmuxp load`. @@ -330,6 +399,12 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="print the tmux commands that would run, without executing them", ) + load_parser.add_argument( + "--no-fold", + dest="fold", + action="store_false", + help="dispatch one tmux call per operation (no ; chaining)", + ) return parser @@ -349,6 +424,7 @@ def main(argv: Sequence[str] | None = None) -> None: new_session_name=args.new_session_name, detached=args.detached, dry_run=args.dry_run, + fold=args.fold, ) diff --git a/tests/experimental/contract/test_workspace_cli.py b/tests/experimental/contract/test_workspace_cli.py index 83dbcee59..a37fe50b1 100644 --- a/tests/experimental/contract/test_workspace_cli.py +++ b/tests/experimental/contract/test_workspace_cli.py @@ -62,6 +62,13 @@ def test_parser_load_arguments() -> None: assert args.detached is True assert args.socket_name == "sock" assert args.new_session_name == "newname" + assert args.fold is True # builds fold by default + + +def test_parser_no_fold_flag() -> None: + """``--no-fold`` opts out of dispatch chaining.""" + args = cli._build_parser().parse_args(["load", "ws.yaml", "--no-fold"]) + assert args.fold is False def test_load_builds_and_reattaches(tmp_path: Path) -> None: @@ -123,5 +130,52 @@ def test_dry_run_prints_commands_without_touching_tmux( assert "echo one" in out # the blank pane sends no command, so exactly one send-keys line is rendered assert out.count("send-keys") == 1 + # the default dry run folds: the header says so and rename + send chain + assert "folded" in out + assert "\\;" in out # nothing was executed: no tmux server exists on the dry-run socket assert not libtmux.Server(socket_name=socket).is_alive() + + +def test_dry_run_folds_split_and_send_into_one_dispatch( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A split pane with a command renders as one {marked} dispatch by default.""" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: dryfold\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - echo two\n", + ) + cli.load(str(tmp_path), socket_name="libtmux_wscli_fold", dry_run=True) + out = capsys.readouterr().out + + # the second pane's split + send-keys collapse into a single {marked} chain + marked = [line for line in out.splitlines() if "{marked}" in line] + assert len(marked) == 1 + assert "split-window" in marked[0] and "send-keys" in marked[0] + assert "\\;" in marked[0] + + +def test_dry_run_no_fold_renders_one_call_per_op( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """``--no-fold`` prints an unchained, one-op-per-line plan.""" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: dryseq\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - echo two\n", + ) + cli.load(str(tmp_path), socket_name="libtmux_wscli_seq", dry_run=True, fold=False) + out = capsys.readouterr().out + + assert "sequential" in out + assert "\\;" not in out # nothing chained + assert "{marked}" not in out # no marked fold From be2b9bd7ce2d9cefe9c99d95755e1cedbec1f9b6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 14:35:14 -0500 Subject: [PATCH 095/223] Ops(feat[new_pane]): Add floating pane operation why: The engine-ops spine had 60 operations but none for tmux 3.7's new-pane (floating panes); the workspace builder, facade, and MCP had nothing to lower a floating pane into. what: - Add NewPane(Operation[SplitWindowResult]) rendering new-pane with absolute floating geometry (-x/-y size, -X/-Y position; cells or N%), -Z/-d/-E, styles, environment, and -P -F capture - Reuse SplitWindowResult so SlotRef binding, facade, and MCP keep working unchanged; first op to set min_version='3.7' (whole-command version gate) - Register + export NewPane; refresh the catalog all-kinds doctest - Cover render/round-trip/registry/version-gate plus a live floating pane test asserting pane_floating_flag on tmux 3.7+ --- src/libtmux/experimental/ops/__init__.py | 2 + src/libtmux/experimental/ops/_ops/__init__.py | 2 + src/libtmux/experimental/ops/_ops/new_pane.py | 183 ++++++++++++++++ src/libtmux/experimental/ops/catalog.py | 2 +- tests/experimental/ops/test_new_pane.py | 200 ++++++++++++++++++ 5 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/libtmux/experimental/ops/_ops/new_pane.py create mode 100644 tests/experimental/ops/test_new_pane.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 28616dd2d..75d58453e 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -43,6 +43,7 @@ LoadBuffer, MovePane, MoveWindow, + NewPane, NewSession, NewWindow, NextWindow, @@ -189,6 +190,7 @@ "MovePane", "MoveWindow", "NameRef", + "NewPane", "NewSession", "NewWindow", "NextWindow", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 17d76abab..26b90789e 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -29,6 +29,7 @@ from libtmux.experimental.ops._ops.load_buffer import LoadBuffer from libtmux.experimental.ops._ops.move_pane import MovePane from libtmux.experimental.ops._ops.move_window import MoveWindow +from libtmux.experimental.ops._ops.new_pane import NewPane from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow from libtmux.experimental.ops._ops.next_window import NextWindow @@ -88,6 +89,7 @@ "LoadBuffer", "MovePane", "MoveWindow", + "NewPane", "NewSession", "NewWindow", "NextWindow", diff --git a/src/libtmux/experimental/ops/_ops/new_pane.py b/src/libtmux/experimental/ops/_ops/new_pane.py new file mode 100644 index 000000000..7409a29f8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_pane.py @@ -0,0 +1,183 @@ +"""The ``new-pane`` operation (tmux 3.7+ floating panes).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewPane(Operation[SplitWindowResult]): + """Create a floating pane (``new-pane``; tmux 3.7+). + + ``new-pane`` shares ``split-window``'s machinery but floats by default: the + pane sits above the tiled layout (popup-style, but non-modal) instead of + becoming a tiled cell. Geometry is *absolute* -- :attr:`width`/:attr:`height` + set the size (``-x``/``-y``) and :attr:`x`/:attr:`y` set the top-left offset + (``-X``/``-Y``); each accepts cells (``int``) or a percentage (``str`` like + ``"50%"``). Omitted position cascades down-right on repeated calls. + + Like :class:`~.split_window.SplitWindow` it reuses + :class:`~.results.SplitWindowResult`, capturing the new pane id via + ``-P -F '#{pane_id}'`` so plans, the facade, and MCP bind it the same way. + + This is the first operation gated by :attr:`~.operation.Operation.min_version`: + rendering against a tmux older than 3.7 raises + :exc:`~.exc.VersionUnsupported`. + + Parameters + ---------- + width : int or str or None + Floating pane width in cells or ``N%`` (``-x``). + height : int or str or None + Floating pane height in cells or ``N%`` (``-y``). + x : int or str or None + Absolute x-position (left offset) in cells or ``N%`` (``-X``). + y : int or str or None + Absolute y-position (top offset) in cells or ``N%`` (``-Y``). + zoom : bool + Zoom the new pane (``-Z``). + detach : bool + Do not focus the new pane (``-d``); defaults ``True`` for headless use. + empty : bool + Create an empty pane with no command (``-E``). + start_directory : str or None + Working directory for the new pane (``-c``). + environment : Mapping[str, str] or None + Environment variables for the new pane (``-e``). + style : str or None + Content style (``-s``). + active_border_style : str or None + Active border style (``-S``). + inactive_border_style : str or None + Inactive border style (``-R``). + message : str or None + Remain-on-exit message (``-m``). + shell_command : str or None + A shell command to run instead of the default shell. + capture : bool + Append ``-P -F '#{pane_id}'`` to capture the new pane id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> NewPane(target=PaneId("%1"), width=80, height=15, x=5, y=3).render() + ('new-pane', '-t', '%1', '-x80', '-y15', '-X5', '-Y3', '-d', '-P', '-F', + '#{pane_id}') + + Percentages and zoom render verbatim: + + >>> NewPane(target=PaneId("%1"), width="50%", height="40%", zoom=True).render() + ('new-pane', '-t', '%1', '-x50%', '-y40%', '-Z', '-d', '-P', '-F', '#{pane_id}') + + Passing ``detach=False`` focuses the new pane (no ``-d``): + + >>> NewPane(target=PaneId("%1"), width=80, height=15, detach=False).render() + ('new-pane', '-t', '%1', '-x80', '-y15', '-P', '-F', '#{pane_id}') + + Floating panes need tmux 3.7+; an older tmux is refused: + + >>> NewPane(target=PaneId("%1")).render(version="3.6") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.VersionUnsupported: operation 'new_pane' + requires tmux >= 3.7 (have 3.6) + + The created pane id is parsed into the typed result: + + >>> result = NewPane(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> result.new_pane_id + '%2' + """ + + kind = "new_pane" + command = "new-pane" + scope = "window" + result_cls = SplitWindowResult + safety = "mutating" + chainable = False # captures a new pane id (-P -F); cannot fold into a ; chain + effects = Effects(creates="pane") + min_version = "3.7" + + width: int | str | None = None + height: int | str | None = None + x: int | str | None = None + y: int | str | None = None + zoom: bool = False + detach: bool = True + empty: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + style: str | None = None + active_border_style: str | None = None + inactive_border_style: str | None = None + message: str | None = None + shell_command: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-pane`` geometry, style, capture, and shell flags.""" + out: list[str] = [] + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + if self.x is not None: + out.append(f"-X{self.x}") + if self.y is not None: + out.append(f"-Y{self.y}") + if self.zoom: + out.append("-Z") + if self.detach: + out.append("-d") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment: + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.style is not None: + out.append(f"-s{self.style}") + if self.active_border_style is not None: + out.append(f"-S{self.active_border_style}") + if self.inactive_border_style is not None: + out.append(f"-R{self.inactive_border_style}") + if self.message is not None: + out.append(f"-m{self.message}") + if self.empty: + out.append("-E") + if self.capture: + out.extend(("-P", "-F", "#{pane_id}")) + if self.shell_command is not None: + out.append(self.shell_command) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> SplitWindowResult: + """Parse the captured new-pane id into the typed result.""" + new_pane_id = stdout[0].strip() if status == "complete" and stdout else None + return SplitWindowResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_pane_id=new_pane_id, + ) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 55ed6118a..888a056c7 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -69,7 +69,7 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_server', 'kill_session', 'kill_window', 'last_pane', 'last_window', 'link_window', 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'load_buffer', - 'move_pane', 'move_window', 'new_session', 'new_window', 'next_window', + 'move_pane', 'move_window', 'new_pane', 'new_session', 'new_window', 'next_window', 'paste_buffer', 'pipe_pane', 'previous_window', 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', 'rotate_window', 'run_shell', 'save_buffer', 'select_layout', 'select_pane', diff --git a/tests/experimental/ops/test_new_pane.py b/tests/experimental/ops/test_new_pane.py new file mode 100644 index 000000000..b936ba813 --- /dev/null +++ b/tests/experimental/ops/test_new_pane.py @@ -0,0 +1,200 @@ +"""Tests for the ``new-pane`` (floating pane) operation.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.common import has_gte_version +from libtmux.experimental.ops import ( + NewPane, + operation_from_dict, + operation_to_dict, + registry, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.exc import VersionUnsupported + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """A ``NewPane`` op and the exact argv it renders.""" + + test_id: str + op: NewPane + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="geometry", + op=NewPane(target=PaneId("%1"), width=80, height=15, x=5, y=3), + expected=( + "new-pane", + "-t", + "%1", + "-x80", + "-y15", + "-X5", + "-Y3", + "-d", + "-P", + "-F", + "#{pane_id}", + ), + ), + RenderCase( + test_id="percentage_zoom", + op=NewPane(target=PaneId("%1"), width="50%", height="40%", zoom=True), + expected=( + "new-pane", + "-t", + "%1", + "-x50%", + "-y40%", + "-Z", + "-d", + "-P", + "-F", + "#{pane_id}", + ), + ), + RenderCase( + test_id="attach_no_detach", + op=NewPane(target=PaneId("%1"), width=80, height=15, detach=False), + expected=( + "new-pane", + "-t", + "%1", + "-x80", + "-y15", + "-P", + "-F", + "#{pane_id}", + ), + ), + RenderCase( + test_id="styles_env_shell", + op=NewPane( + target=PaneId("%1"), + start_directory="/tmp", + environment={"E": "1"}, + style="bg=default", + active_border_style="fg=magenta", + inactive_border_style="fg=cyan", + message="done", + empty=True, + shell_command="lazygit", + ), + expected=( + "new-pane", + "-t", + "%1", + "-d", + "-c/tmp", + "-eE=1", + "-sbg=default", + "-Sfg=magenta", + "-Rfg=cyan", + "-mdone", + "-E", + "-P", + "-F", + "#{pane_id}", + "lazygit", + ), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_new_pane_render( + test_id: str, + op: NewPane, + expected: tuple[str, ...], +) -> None: + """Each ``NewPane`` configuration renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_new_pane_round_trips( + test_id: str, + op: NewPane, + expected: tuple[str, ...], +) -> None: + """The op and its result round-trip through dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0, stdout=("%2",)) + assert result_from_dict(result_to_dict(result)) == result + + +def test_new_pane_is_registered() -> None: + """``NewPane`` is discoverable in the operation registry by kind.""" + assert "new_pane" in registry + assert registry.operation("new_pane") is NewPane + + +def test_new_pane_captures_new_pane_id() -> None: + """new-pane parses the captured pane id into the typed result.""" + result = NewPane(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + assert result.new_pane_id == "%2" + assert result.created_id == "%2" + + +def test_new_pane_requires_tmux_3_7() -> None: + """Rendering against tmux older than 3.7 raises (the spine's first gate).""" + op = NewPane(target=PaneId("%1"), width=80, height=15) + with pytest.raises(VersionUnsupported, match=r"requires tmux >= 3.7"): + op.render(version="3.6") + + +def test_new_pane_renders_on_supported_version() -> None: + """No version (latest) or tmux >= 3.7 renders without error.""" + op = NewPane(target=PaneId("%1"), width=80, height=15) + assert op.render()[0] == "new-pane" + assert op.render(version="3.7")[0] == "new-pane" + + +@pytest.mark.skipif( + not has_gte_version("3.7"), + reason="new-pane (floating panes) requires tmux 3.7+", +) +def test_new_pane_live(session: Session) -> None: + """new-pane creates a real floating pane against tmux 3.7+.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + result = run( + NewPane(target=PaneId(pane.pane_id), width=40, height=10, x=5, y=3), + engine, + ) + assert result.ok + assert result.new_pane_id is not None + assert session.server.panes.get(pane_id=result.new_pane_id) is not None + + floating = session.server.cmd( + "display-message", + "-p", + "-t", + result.new_pane_id, + "#{pane_floating_flag}", + ) + assert floating.stdout == ["1"] From ee15884d68596e0e17bd45e16cb5f3cabfea0b32 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 14:40:14 -0500 Subject: [PATCH 096/223] Ops(fix[break_pane]): Work around tmux 3.7 break-pane crash why: tmux 3.7 NULL-derefs the server on a nameless break-pane (fixed upstream after 3.7) and ignores -n when one is given. The experimental BreakPane op emitted no -n for nameless breaks, crashing the 3.7 server. Mirrors the fix already shipped in Pane.break_pane (#693). what: - Inject a placeholder -n on exactly tmux 3.7 when no name is requested - Gate via _normalize_tmux_version exact match; other builds render bare - Document the workaround and cover placeholder/bare/named render paths The gate fires only when a tmux version reaches args(); the engine version resolution that activates it for live runs lands next. --- .../experimental/ops/_ops/break_pane.py | 30 ++++++++++++++ tests/experimental/ops/test_pane_ops.py | 41 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/libtmux/experimental/ops/_ops/break_pane.py b/src/libtmux/experimental/ops/_ops/break_pane.py index 1034def13..9f728f8bf 100644 --- a/src/libtmux/experimental/ops/_ops/break_pane.py +++ b/src/libtmux/experimental/ops/_ops/break_pane.py @@ -9,11 +9,23 @@ from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.registry import register from libtmux.experimental.ops.results import CreateResult +from libtmux.neo import _normalize_tmux_version if t.TYPE_CHECKING: from libtmux.experimental.ops._types import Status +def _breaks_without_name(version: str | None) -> bool: + """Whether this tmux needs a placeholder ``-n`` for a nameless break-pane. + + tmux 3.7 NULL-derefs ``break-pane`` when ``-n`` is absent (fixed upstream + after 3.7), so exactly 3.7 must be handed a placeholder name. + """ + if version is None: + return False + return _normalize_tmux_version(version) == _normalize_tmux_version("3.7") + + @register @dataclass(frozen=True, kw_only=True) class BreakPane(Operation[CreateResult]): @@ -32,11 +44,27 @@ class BreakPane(Operation[CreateResult]): capture : bool Append ``-P -F '#{window_id}'`` to capture the new window id. + Notes + ----- + tmux 3.7 crashes the server on a nameless ``break-pane`` (a NULL-deref fixed + upstream after 3.7) and ignores ``-n`` when one is given. To survive, exactly + 3.7 is handed a placeholder ``-n`` when no name was requested; a higher layer + renames the window afterward when a name is wanted. + Examples -------- >>> from libtmux.experimental.ops._types import PaneId >>> BreakPane(src_target=PaneId("%2"), name="logs").render() ('break-pane', '-d', '-n', 'logs', '-P', '-F', '#{window_id}', '-s', '%2') + + On exactly tmux 3.7 a nameless break-pane is given a placeholder name; other + builds render it bare: + + >>> BreakPane(src_target=PaneId("%2")).render(version="3.7") + ('break-pane', '-d', '-n', 'libtmux', '-P', '-F', '#{window_id}', '-s', '%2') + >>> BreakPane(src_target=PaneId("%2")).render(version="3.8") + ('break-pane', '-d', '-P', '-F', '#{window_id}', '-s', '%2') + >>> BreakPane(src_target=PaneId("%2")).build_result( ... returncode=0, stdout=("@7",) ... ).new_id @@ -62,6 +90,8 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out.append("-d") if self.name is not None: out.extend(("-n", self.name)) + elif _breaks_without_name(version): + out.extend(("-n", "libtmux")) if self.capture: out.extend(("-P", "-F", "#{window_id}")) out.extend(self.src_args()) diff --git a/tests/experimental/ops/test_pane_ops.py b/tests/experimental/ops/test_pane_ops.py index eecad6c72..993ed1187 100644 --- a/tests/experimental/ops/test_pane_ops.py +++ b/tests/experimental/ops/test_pane_ops.py @@ -143,6 +143,47 @@ def test_pane_op_round_trips( assert result_from_dict(result_to_dict(result)) == result +def test_break_pane_placeholder_name_on_tmux_3_7() -> None: + """Tmux 3.7 gets a placeholder -n to dodge a nameless break-pane crash.""" + op = BreakPane(src_target=PaneId("%2")) + assert op.render(version="3.7") == ( + "break-pane", + "-d", + "-n", + "libtmux", + "-P", + "-F", + "#{window_id}", + "-s", + "%2", + ) + + +def test_break_pane_no_placeholder_off_3_7() -> None: + """Only exactly 3.7 gets the workaround; other versions render bare.""" + op = BreakPane(src_target=PaneId("%2")) + bare = ("break-pane", "-d", "-P", "-F", "#{window_id}", "-s", "%2") + assert op.render() == bare + assert op.render(version="3.6") == bare + assert op.render(version="3.8") == bare + + +def test_break_pane_named_unaffected_on_3_7() -> None: + """A requested name is passed through (no placeholder) on 3.7.""" + op = BreakPane(src_target=PaneId("%2"), name="logs") + assert op.render(version="3.7") == ( + "break-pane", + "-d", + "-n", + "logs", + "-P", + "-F", + "#{window_id}", + "-s", + "%2", + ) + + def test_break_pane_captures_new_window_id() -> None: """break-pane parses the captured window id into the typed result.""" result = BreakPane(src_target=PaneId("%2")).build_result( From 0bf1c7b4a5ae3c8fe197efd66532a64aab04e8f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 14:48:28 -0500 Subject: [PATCH 097/223] Engines(feat): Resolve engine tmux version for gating why: Operations are version-aware, but execution defaulted to version=None, so version-gating (flag drops, whole-command gates, the break-pane 3.7 workaround) silently did nothing unless a caller threaded the version by hand. This is why test_break_and_swap_live still crashed even with the BreakPane workaround in place. what: - Add the optional SupportsTmuxVersion engine capability (base.py) and implement tmux_version() on the subprocess + asyncio engines (memoized `tmux -V`, None when unknown) - Add resolve_engine_version() and use it in run()/arun() and at the LazyPlan execute()/aexecute() entry points so the live tmux version reaches rendering when the caller passes none - Explicit version still wins; engines without the capability assume latest, so fakes and the in-memory engine are unaffected - Cover resolution + gating activation for run/arun and a folded plan; this greens test_break_and_swap_live on tmux 3.7 --- src/libtmux/experimental/engines/__init__.py | 2 + src/libtmux/experimental/engines/asyncio.py | 17 +++++ src/libtmux/experimental/engines/base.py | 18 +++++ .../experimental/engines/subprocess.py | 17 +++++ src/libtmux/experimental/ops/execute.py | 39 +++++++++- src/libtmux/experimental/ops/plan.py | 4 +- tests/experimental/ops/test_execute.py | 74 +++++++++++++++++++ tests/experimental/ops/test_plan.py | 21 ++++++ 8 files changed, 190 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 9b756af4c..3969e73f5 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -23,6 +23,7 @@ CommandResult, EngineKind, EngineSpec, + SupportsTmuxVersion, TmuxEngine, ) from libtmux.experimental.engines.concrete import AsyncConcreteEngine, ConcreteEngine @@ -55,6 +56,7 @@ "EngineSpec", "ImsgEngine", "SubprocessEngine", + "SupportsTmuxVersion", "TmuxEngine", "available_engines", "create_engine", diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py index 7ce87d0d8..e5717a6b5 100644 --- a/src/libtmux/experimental/engines/asyncio.py +++ b/src/libtmux/experimental/engines/asyncio.py @@ -16,6 +16,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult if t.TYPE_CHECKING: @@ -54,6 +55,22 @@ def __init__( self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None self.server_args = tuple(server_args) self._resolved_bin: str | None = None + self._tmux_version: str | None = None + self._version_probed = False + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Returns ``None`` when the binary is missing or its version cannot be + parsed, so version resolution degrades to "assume latest". + """ + if not self._version_probed: + self._version_probed = True + try: + self._tmux_version = str(get_version(self._resolve_bin())) + except exc.LibTmuxException: + self._tmux_version = None + return self._tmux_version def _resolve_bin(self) -> str: """Return the tmux binary path, memoized for the engine instance.""" diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 8516eb028..f5c6bfd49 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -189,3 +189,21 @@ async def run_batch( ) -> list[CommandResult]: """Execute requests in order, returning one result per request.""" ... + + +@t.runtime_checkable +class SupportsTmuxVersion(t.Protocol): + """An engine that can report the tmux version it targets. + + Optional engine capability. The executors + (:func:`~libtmux.experimental.ops.execute.run` / ``arun`` and the + :class:`~libtmux.experimental.ops.plan.LazyPlan` drivers) call + :meth:`tmux_version` to resolve the version for version-gated rendering when + the caller passes none. Engines that cannot know their version -- in-memory + or fake engines -- simply do not implement it, and resolution falls back to + "assume latest". + """ + + def tmux_version(self) -> str | None: + """Return the engine's tmux version string, or ``None`` if unknown.""" + ... diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py index 639d829e3..8a6213331 100644 --- a/src/libtmux/experimental/engines/subprocess.py +++ b/src/libtmux/experimental/engines/subprocess.py @@ -16,6 +16,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult if t.TYPE_CHECKING: @@ -46,6 +47,22 @@ def __init__( self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None self.server_args = tuple(server_args) self._resolved_bin: str | None = None + self._tmux_version: str | None = None + self._version_probed = False + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Returns ``None`` when the binary is missing or its version cannot be + parsed, so version resolution degrades to "assume latest". + """ + if not self._version_probed: + self._version_probed = True + try: + self._tmux_version = str(get_version(self._resolve_bin())) + except exc.LibTmuxException: + self._tmux_version = None + return self._tmux_version def _resolve_bin(self) -> str: """Return the tmux binary path, memoized for the engine instance.""" diff --git a/src/libtmux/experimental/ops/execute.py b/src/libtmux/experimental/ops/execute.py index 4197dcfce..a220771d7 100644 --- a/src/libtmux/experimental/ops/execute.py +++ b/src/libtmux/experimental/ops/execute.py @@ -11,7 +11,7 @@ import typing as t -from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.engines.base import CommandRequest, SupportsTmuxVersion if t.TYPE_CHECKING: import pathlib @@ -23,6 +23,41 @@ ResultT = t.TypeVar("ResultT", bound="Result") +def resolve_engine_version( + engine: TmuxEngine | AsyncTmuxEngine, + version: str | None, +) -> str | None: + """Resolve the tmux version to render against. + + Returns *version* unchanged when the caller supplied one. Otherwise asks the + engine via the optional + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` capability, + so version-gated rendering (flag drops and whole-command gates) reflects the + live tmux at runtime instead of silently assuming latest. Engines that cannot + report a version fall back to ``None`` ("assume latest"). + + Examples + -------- + >>> from libtmux.experimental.engines import CommandResult + >>> class VersionedEngine: + ... def run(self, request): + ... return CommandResult(cmd=("tmux", *request.args), returncode=0) + ... def run_batch(self, requests): + ... return [self.run(r) for r in requests] + ... def tmux_version(self): + ... return "2.9" + >>> resolve_engine_version(VersionedEngine(), None) + '2.9' + >>> resolve_engine_version(VersionedEngine(), "3.4") + '3.4' + """ + if version is not None: + return version + if isinstance(engine, SupportsTmuxVersion): + return engine.tmux_version() + return None + + def run( operation: Operation[ResultT], engine: TmuxEngine, @@ -65,6 +100,7 @@ def run( >>> result.argv ('send-keys', '-t', '%1', 'echo hi') """ + version = resolve_engine_version(engine, version) rendered = operation.render(version=version) raw = engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) return operation.build_result( @@ -106,6 +142,7 @@ async def arun( >>> result.lines ('line-1', 'line-2') """ + version = resolve_engine_version(engine, version) rendered = operation.render(version=version) raw = await engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) return operation.build_result( diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 84d085f7e..8b3bb52f6 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -33,7 +33,7 @@ WindowId, ) from libtmux.experimental.ops.exc import ForwardCaptureError -from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.execute import arun, resolve_engine_version, run from libtmux.experimental.ops.planner import Planner, PlanStep, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict @@ -352,6 +352,7 @@ def execute( bind, so a caller can interleave host-side work between dispatches; it is a no-op trampoline hop when ``None``. """ + version = resolve_engine_version(engine, version) gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) @@ -388,6 +389,7 @@ async def aexecute( Mirrors :meth:`execute`; *on_step* is awaited per step. """ + version = resolve_engine_version(engine, version) gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py index 017ad6ba1..8a0ef83fb 100644 --- a/tests/experimental/ops/test_execute.py +++ b/tests/experimental/ops/test_execute.py @@ -99,6 +99,80 @@ def test_run_version_threads_through() -> None: assert "-T" not in result.argv +class VersionedFakeEngine(FakeEngine): + """A sync fake engine that reports a fixed tmux version.""" + + def __init__( + self, + *, + tmux_version: str | None, + stdout: tuple[str, ...] = (), + returncode: int = 0, + ) -> None: + super().__init__(stdout=stdout, returncode=returncode) + self._tmux_version = tmux_version + + def tmux_version(self) -> str | None: + """Report the canned tmux version.""" + return self._tmux_version + + +def test_resolve_engine_version_prefers_explicit() -> None: + """An explicit version always wins over the engine's reported version.""" + from libtmux.experimental.ops.execute import resolve_engine_version + + engine = VersionedFakeEngine(tmux_version="2.9") + assert resolve_engine_version(engine, "3.4") == "3.4" + + +def test_resolve_engine_version_falls_back_to_engine() -> None: + """With no explicit version, the engine's reported version is used.""" + from libtmux.experimental.ops.execute import resolve_engine_version + + engine = VersionedFakeEngine(tmux_version="2.9") + assert resolve_engine_version(engine, None) == "2.9" + + +def test_resolve_engine_version_none_without_capability() -> None: + """A plain engine (no version capability) resolves to None (assume latest).""" + from libtmux.experimental.ops.execute import resolve_engine_version + + assert resolve_engine_version(FakeEngine(), None) is None + + +def test_run_auto_resolves_engine_version() -> None: + """run() asks the engine for its version when none is passed; gating fires.""" + from libtmux.experimental.ops import CapturePane + + engine = VersionedFakeEngine(tmux_version="3.3") + result = run(CapturePane(target=PaneId("%1"), trim_trailing=True), engine) + assert "-T" not in result.argv # -T is gated >= 3.4, dropped on resolved 3.3 + + +def test_run_without_version_capability_renders_every_flag() -> None: + """A plain engine has no version, so version-gated flags are kept.""" + from libtmux.experimental.ops import CapturePane + + engine = FakeEngine() + result = run(CapturePane(target=PaneId("%1"), trim_trailing=True), engine) + assert "-T" in result.argv + + +def test_arun_auto_resolves_engine_version() -> None: + """arun() resolves the engine version on the async path too.""" + from libtmux.experimental.ops import CapturePane + + class AsyncVersionedFakeEngine(AsyncFakeEngine): + def tmux_version(self) -> str | None: + return "3.3" + + engine = AsyncVersionedFakeEngine() + result = asyncio.run( + arun(CapturePane(target=PaneId("%1"), trim_trailing=True), engine), + ) + assert "-T" not in result.argv + + def test_arun_shares_render_and_build() -> None: """``arun`` produces the same typed result as ``run`` via the async path.""" engine = AsyncFakeEngine(stdout=("%5",)) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 0737c7961..cb5a8f24b 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -49,6 +49,27 @@ def test_plan_resolves_forward_ref() -> None: assert outcome.ok +def test_plan_execute_auto_resolves_engine_version() -> None: + """plan.execute() resolves the engine version so folded renders are gated.""" + from libtmux.experimental.ops import FoldingPlanner, RespawnPane + + class VersionedConcreteEngine(ConcreteEngine): + def tmux_version(self) -> str | None: + return "2.9" + + plan = LazyPlan() + plan.add(RespawnPane(target=PaneId("%1"), environment={"E": "1"})) + plan.add(RespawnPane(target=PaneId("%2"), environment={"E": "2"})) + + outcome = plan.execute(VersionedConcreteEngine(), planner=FoldingPlanner()) + + # -e is gated at tmux 3.0; on the engine's resolved 2.9 it is dropped even + # from the folded (rendered-in-_drive) dispatch. + assert outcome.ok + for result in outcome.results: + assert not any(arg.startswith("-e") for arg in result.argv) + + class SrcResolveCase(t.NamedTuple): """A dual-target op whose ``src_target`` is a forward :class:`SlotRef`.""" From 92403aaf3ceda7ccd12175a6f21696911a00f81e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:04:47 -0500 Subject: [PATCH 098/223] Workspace(feat[ir]): Add floating-pane declarations why: The declarative workspace IR had no way to express tmux 3.7 floating panes; a user could not declare a floating overlay (e.g. a lazygit popup) in a spec at all. what: - Add a Float geometry value type (width/height -> -x/-y size, x/y -> -X/-Y position; cells or N%) and FloatingPane (a Pane + Float + attach_to) - Add Window.floats: Sequence[FloatingPane] overlays, kept as a plain declarative data shape like panes (NOT a live QueryList -- QueryList is the live object-query layer, not the spec) - Round-trip floats through analyze()/to_dict(); export Float + FloatingPane from the workspace package - Cover to_dict, defaults, and round-trip Inert data only; the compiler emit + events/confirm wiring lands next. --- .../experimental/workspace/__init__.py | 11 +- .../experimental/workspace/analyzer.py | 37 +++++- src/libtmux/experimental/workspace/ir.py | 121 ++++++++++++++++++ .../contract/test_workspace_floats.py | 83 ++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 tests/experimental/contract/test_workspace_floats.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index aff78a4fa..99b572142 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -38,7 +38,14 @@ WindowCreated, WorkspaceBuilt, ) -from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace +from libtmux.experimental.workspace.ir import ( + Command, + Float, + FloatingPane, + Pane, + Window, + Workspace, +) from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace __all__ = ( @@ -46,6 +53,8 @@ "Command", "Compiled", "ConfirmReport", + "Float", + "FloatingPane", "HostStep", "Pane", "PaneCreated", diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 0cb3d9c44..9eda00977 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -31,7 +31,14 @@ import collections.abc import typing as t -from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace +from libtmux.experimental.workspace.ir import ( + Command, + Float, + FloatingPane, + Pane, + Window, + Workspace, +) def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: @@ -90,6 +97,34 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: window_shell=raw.get("window_shell"), window_index=raw.get("window_index"), panes=[_pane(p) for p in raw.get("panes", []) or []], + floats=[_floating_pane(f) for f in raw.get("floats", []) or []], + ) + + +def _floating_pane(raw: collections.abc.Mapping[str, t.Any]) -> FloatingPane: + """Normalize one floating-pane config (a pane config plus ``float``).""" + return FloatingPane( + pane=_pane(raw), + geometry=_float(raw.get("float")), + attach_to=raw.get("attach_to"), + ) + + +def _float(raw: t.Any) -> Float: + """Coerce a ``float`` geometry value (mapping / None / marker) into a Float.""" + if not isinstance(raw, collections.abc.Mapping): + return Float() + return Float( + width=raw.get("width"), + height=raw.get("height"), + x=raw.get("x"), + y=raw.get("y"), + zoom=bool(raw.get("zoom", False)), + empty=bool(raw.get("empty", False)), + style=raw.get("style"), + active_border_style=raw.get("active_border_style"), + inactive_border_style=raw.get("inactive_border_style"), + message=raw.get("message"), ) diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 552e2cca3..3dd57305d 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -166,6 +166,120 @@ def to_dict(self) -> dict[str, t.Any]: return out +@dataclass(frozen=True) +class Float: + """Absolute geometry for a floating pane (tmux 3.7 ``new-pane``). + + Floating panes are popup-style overlays, not tiled cells, so their geometry + is absolute rather than split-relative. :attr:`width`/:attr:`height` set the + size (``-x``/``-y``) and :attr:`x`/:attr:`y` set the top-left offset + (``-X``/``-Y``); each is cells (``int``) or a percentage (``str`` like + ``"50%"``). The remaining fields mirror the ``new-pane`` flag vocabulary. + + Parameters + ---------- + width, height : int or str or None + Size in cells or ``N%`` (``-x`` / ``-y``). + x, y : int or str or None + Absolute position in cells or ``N%`` (``-X`` / ``-Y``). + zoom : bool + Zoom the pane (``-Z``). + empty : bool + Create an empty pane with no command (``-E``). + style, active_border_style, inactive_border_style : str or None + Content / active-border / inactive-border styles (``-s`` / ``-S`` / + ``-R``). + message : str or None + Remain-on-exit message (``-m``). + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Float + >>> Float(width=120, height=40, x="C", y="C").to_dict() + {'width': 120, 'height': 40, 'x': 'C', 'y': 'C'} + >>> Float().to_dict() + {} + """ + + width: int | str | None = None + height: int | str | None = None + x: int | str | None = None + y: int | str | None = None + zoom: bool = False + empty: bool = False + style: str | None = None + active_border_style: str | None = None + inactive_border_style: str | None = None + message: str | None = None + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical float-geometry config (omitting defaults).""" + out: dict[str, t.Any] = {} + if self.width is not None: + out["width"] = self.width + if self.height is not None: + out["height"] = self.height + if self.x is not None: + out["x"] = self.x + if self.y is not None: + out["y"] = self.y + if self.zoom: + out["zoom"] = True + if self.empty: + out["empty"] = True + if self.style is not None: + out["style"] = self.style + if self.active_border_style is not None: + out["active_border_style"] = self.active_border_style + if self.inactive_border_style is not None: + out["inactive_border_style"] = self.inactive_border_style + if self.message is not None: + out["message"] = self.message + return out + + +@dataclass(frozen=True) +class FloatingPane: + """A floating-pane overlay: a :class:`Pane` plus its :class:`Float` geometry. + + Overlays are *not* tiled cells -- the compiler emits them as ``new-pane`` and + keeps them out of the tiled split chain, the ``select-layout`` call, and the + pane-count check. :attr:`attach_to` names a :class:`Window` (by name) to float + over; ``None`` floats over the window the overlay is declared on. + + Parameters + ---------- + pane : Pane + The pane's command(s), focus, env, shell, etc. + geometry : Float + The floating geometry; defaults to tmux's own (half-width, quarter-height, + cascading position). + attach_to : str or None + Name of the window to float over; ``None`` for the host window. + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Float, FloatingPane, Pane + >>> fp = FloatingPane(pane=Pane(run="lazygit"), geometry=Float(width="60%")) + >>> fp.to_dict()["shell_command"] + ['lazygit'] + >>> fp.to_dict()["float"] + {'width': '60%'} + """ + + pane: Pane = field(default_factory=Pane) + geometry: Float = field(default_factory=Float) + attach_to: str | None = None + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a pane config plus a ``float`` geometry (and ``attach_to``).""" + out = self.pane.to_dict() + out["float"] = self.geometry.to_dict() + if self.attach_to is not None: + out["attach_to"] = self.attach_to + return out + + @dataclass(frozen=True) class Window: """A window in the declared workspace. @@ -200,6 +314,10 @@ class Window: the session's implicit window and keeps the session base index. panes : Sequence[Pane] The window's panes (the first reuses the window's implicit pane). + floats : Sequence[FloatingPane] + Floating-pane overlays for this window (tmux 3.7+). Overlays are not + tiled cells: they are created with ``new-pane`` after the layout and are + excluded from the split chain and the tiled pane count. """ name: str | None = None @@ -212,6 +330,7 @@ class Window: window_shell: str | None = None window_index: int | None = None panes: Sequence[Pane] = () + floats: Sequence[FloatingPane] = () def to_dict(self) -> dict[str, t.Any]: """Serialize to a canonical tmuxp window config (inverse of the analyzer).""" @@ -235,6 +354,8 @@ def to_dict(self) -> dict[str, t.Any]: if self.window_index is not None: out["window_index"] = self.window_index out["panes"] = [pane.to_dict() for pane in self.panes] + if self.floats: + out["floats"] = [fp.to_dict() for fp in self.floats] return out diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py new file mode 100644 index 000000000..7f4cc206e --- /dev/null +++ b/tests/experimental/contract/test_workspace_floats.py @@ -0,0 +1,83 @@ +"""Tests for floating-pane declarations in the workspace IR (tmux 3.7+).""" + +from __future__ import annotations + +from libtmux.experimental.workspace import ( + Float, + FloatingPane, + Pane, + Window, + Workspace, + analyze, +) + + +def test_float_to_dict_omits_defaults() -> None: + """Float.to_dict drops fields left at their default.""" + assert Float().to_dict() == {} + assert Float(width=120, height=40, x="C", y="C", zoom=True).to_dict() == { + "width": 120, + "height": 40, + "x": "C", + "y": "C", + "zoom": True, + } + + +def test_floating_pane_to_dict() -> None: + """A FloatingPane serializes as a pane config plus float geometry + attach_to.""" + fp = FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width="60%"), + attach_to="editor", + ) + out = fp.to_dict() + assert out["shell_command"] == ["lazygit"] + assert out["float"] == {"width": "60%"} + assert out["attach_to"] == "editor" + + +def test_window_floats_default_empty() -> None: + """A window declared without floats has no overlays.""" + assert Window("editor").floats == () + + +def test_window_holds_declared_floats() -> None: + """Window.floats preserves the declared overlay specs in order.""" + window = Window( + "editor", + floats=[ + FloatingPane(pane=Pane(run="lazygit"), attach_to=None), + FloatingPane(pane=Pane(run="htop"), attach_to="logs"), + ], + ) + assert [fp.pane.run for fp in window.floats] == ["lazygit", "htop"] + assert [fp.attach_to for fp in window.floats] == [None, "logs"] + + +def test_floats_round_trip_through_to_dict() -> None: + """analyze(ws.to_dict()) reconstructs an equivalent workspace including floats.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width="60%", height="50%", zoom=True), + attach_to="editor", + ), + ], + ), + ], + ) + + revived = analyze(ws.to_dict()) + floated = revived.windows[0].floats[0] + assert [c.cmd for c in floated.pane.commands] == ["lazygit"] + assert floated.geometry.width == "60%" + assert floated.geometry.zoom is True + assert floated.attach_to == "editor" + assert revived.to_dict() == ws.to_dict() From 6ccf695aaae3e7be340e5c0f5ac8150643f04287 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:10:57 -0500 Subject: [PATCH 099/223] Workspace(feat[compiler]): Build floating panes from specs why: Declared floating panes (Commit prior) were inert -- the compiler had no branch to lower them, so a float-bearing workspace ignored its overlays. what: - Factor per-pane command sending into _emit_pane_commands, shared by tiled panes and floats (uniform wait_pane / suppress_history / sleeps) - Emit each Window.floats overlay as a NewPane after the tiled layout, targeting the window's first pane and kept out of the split chain and select-layout; send the float's own commands and honor its focus - events: emit PaneCreated for new_pane; confirm: fold floats into the expected pane count (tiled + floats) so confirm() does not flag a spurious mismatch - Reject cross-window attach_to for now (the symbol table lands next) - Cover compile order, geometry/command emission, the attach_to guard, an offline in-memory build, and the new_pane event --- .../experimental/workspace/compiler.py | 155 +++++++++++++----- src/libtmux/experimental/workspace/confirm.py | 4 +- src/libtmux/experimental/workspace/events.py | 2 +- .../contract/test_workspace_floats.py | 102 ++++++++++++ 4 files changed, 217 insertions(+), 46 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 5a4577118..78f48d528 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -29,6 +29,7 @@ from libtmux.experimental.ops import ( LazyPlan, + NewPane, NewSession, NewWindow, RenameWindow, @@ -116,6 +117,111 @@ def _schedule_before( host_after.setdefault(after, []).append(step) +def _emit_pane_commands( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + window: Window, + pane: Pane, + target: SlotRef, +) -> None: + """Emit a pane's command sends with their host-side sleep / wait scheduling. + + Shared by tiled panes and floating-pane overlays so both honor ``wait_pane``, + ``suppress_history``, and the per-pane / per-command sleeps identically. + """ + commands = pane.commands + if not commands: + return + if ws.wait_pane and (pane.shell or window.window_shell) is None: + # Wait for the pane's shell prompt before sending keys (anti-race); a pane + # launching a custom shell/command does not get this wait, mirroring + # tmuxp's `if pane_shell is None`. + _schedule_before(host_after, pre, len(plan), HostStep("wait_pane", pane=target)) + if pane.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=pane.sleep_before), + ) + for command in commands: + if command.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=command.sleep_before), + ) + plan.add( + SendKeys( + target=target, + keys=command.cmd, + enter=command.enter, + suppress_history=pane.suppress_history, + ), + ) + if command.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=command.sleep_after), + ) + if pane.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=pane.sleep_after), + ) + + +def _emit_floats( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + window: Window, + first_pane_ref: SlotRef, +) -> None: + """Emit the window's floating-pane overlays (tmux 3.7 ``new-pane``). + + Overlays are created *after* the tiled panes and layout, target the window's + first pane, and are deliberately kept out of the split chain and the layout so + they never perturb the tiled topology. Cross-window ``attach_to`` (floating + over a *different* window) is not yet supported. + """ + for fp in window.floats: + if fp.attach_to is not None and fp.attach_to != window.name: + msg = ( + f"floating pane attach_to={fp.attach_to!r} targets another window; " + "cross-window floats are not yet supported" + ) + raise WorkspaceCompileError(msg) + geo = fp.geometry + float_ref = plan.add( + NewPane( + target=first_pane_ref, + width=geo.width, + height=geo.height, + x=geo.x, + y=geo.y, + zoom=geo.zoom, + empty=geo.empty, + style=geo.style, + active_border_style=geo.active_border_style, + inactive_border_style=geo.inactive_border_style, + message=geo.message, + start_directory=( + fp.pane.start_directory + or window.start_directory + or ws.start_directory + ), + environment=dict(fp.pane.environment) or None, + shell_command=fp.pane.shell, + ), + ) + _emit_pane_commands(plan, host_after, pre, ws, window, fp.pane, float_ref) + if fp.pane.focus: + plan.add(SelectPane(target=float_ref)) + + def _emit_window( plan: LazyPlan, host_after: dict[int, list[HostStep]], @@ -125,7 +231,7 @@ def _emit_window( window_ref: SlotRef, first_pane_ref: SlotRef, ) -> None: - """Emit a window's options, panes, sends, layout, and pane focus. + """Emit a window's options, panes, sends, layout, pane focus, and floats. *window_ref* addresses the window (rename/options/layout); *first_pane_ref* is the captured id of the window's first pane. @@ -154,49 +260,7 @@ def _emit_window( shell=pane.shell or window.window_shell, ), ) - commands = pane.commands - if commands: - if ws.wait_pane and (pane.shell or window.window_shell) is None: - # Wait for the pane's shell prompt before sending keys (anti-race); - # a pane launching a custom shell/command does not get this wait, - # mirroring tmuxp's `if pane_shell is None`. - _schedule_before( - host_after, - pre, - len(plan), - HostStep("wait_pane", pane=target), - ) - if pane.sleep_before is not None: - _schedule_before( - host_after, - pre, - len(plan), - HostStep("sleep", seconds=pane.sleep_before), - ) - for command in commands: - if command.sleep_before is not None: - _schedule_before( - host_after, - pre, - len(plan), - HostStep("sleep", seconds=command.sleep_before), - ) - plan.add( - SendKeys( - target=target, - keys=command.cmd, - enter=command.enter, - suppress_history=pane.suppress_history, - ), - ) - if command.sleep_after is not None: - host_after.setdefault(len(plan) - 1, []).append( - HostStep("sleep", seconds=command.sleep_after), - ) - if pane.sleep_after is not None: - host_after.setdefault(len(plan) - 1, []).append( - HostStep("sleep", seconds=pane.sleep_after), - ) + _emit_pane_commands(plan, host_after, pre, ws, window, pane, target) if pane.focus: focus_targets.append(target) prev = target @@ -208,6 +272,9 @@ def _emit_window( for key, value in window.options_after.items(): plan.add(SetWindowOption(target=window_ref, option=key, value=value)) + # Floating overlays come last: after the tiled layout, excluded from it. + _emit_floats(plan, host_after, pre, ws, window, first_pane_ref) + def _creator_environment(window: Window) -> dict[str, str]: """Env for a window's *first* (implicit) pane, applied via its creator's ``-e``. diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py index 020f2e992..12b958a54 100644 --- a/src/libtmux/experimental/workspace/confirm.py +++ b/src/libtmux/experimental/workspace/confirm.py @@ -44,7 +44,9 @@ def confirm(ws: Workspace, server: Server, *, timeout: float = 5.0) -> ConfirmRe f"window name {live.window_name!r} != declared {spec.name!r}" ) live_panes = list(live.panes) - expected_panes = max(1, len(spec.panes)) + # Floating overlays are real panes in the live window but not tiled cells, + # so the expected total is the tiled panes plus the declared floats. + expected_panes = max(1, len(spec.panes)) + len(spec.floats) if len(live_panes) != expected_panes: problems.append( f"window {spec.name!r} pane count " diff --git a/src/libtmux/experimental/workspace/events.py b/src/libtmux/experimental/workspace/events.py index 0899c45c4..6f6502da7 100644 --- a/src/libtmux/experimental/workspace/events.py +++ b/src/libtmux/experimental/workspace/events.py @@ -77,6 +77,6 @@ def events_for(op: Operation[t.Any], result: Result) -> list[BuildEvent]: events.append(WindowCreated(result.created_id)) if "pane" in result.created_subids: events.append(PaneCreated(result.created_subids["pane"])) - elif op.kind == "split_window" and result.created_id is not None: + elif op.kind in {"split_window", "new_pane"} and result.created_id is not None: events.append(PaneCreated(result.created_id)) return events diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py index 7f4cc206e..08486f8c5 100644 --- a/tests/experimental/contract/test_workspace_floats.py +++ b/tests/experimental/contract/test_workspace_floats.py @@ -2,13 +2,18 @@ from __future__ import annotations +import pytest + +from libtmux.experimental.ops import NewPane, SendKeys from libtmux.experimental.workspace import ( Float, FloatingPane, Pane, Window, Workspace, + WorkspaceCompileError, analyze, + compile_workspace, ) @@ -81,3 +86,100 @@ def test_floats_round_trip_through_to_dict() -> None: assert floated.geometry.zoom is True assert floated.attach_to == "editor" assert revived.to_dict() == ws.to_dict() + + +def test_compiler_emits_new_pane_after_layout() -> None: + """A declared float compiles to a new-pane op after the tiled layout.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + layout="main-vertical", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width=120, height=40), + ), + ], + ), + ], + ) + kinds = [op.kind for op in compile_workspace(ws).operations] + assert "new_pane" in kinds + assert kinds.index("new_pane") > kinds.index("select_layout") + + +def test_compiler_new_pane_geometry_and_command() -> None: + """The emitted new-pane carries the float geometry and sends its command.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width="60%", height="50%"), + ), + ], + ), + ], + ) + ops = compile_workspace(ws).operations + new_pane = next(op for op in ops if isinstance(op, NewPane)) + assert (new_pane.width, new_pane.height) == ("60%", "50%") + assert any(isinstance(op, SendKeys) and op.keys == "lazygit" for op in ops) + + +def test_cross_window_attach_to_raises() -> None: + """A float attaching to a different window is rejected (not yet supported).""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[FloatingPane(pane=Pane(run="lazygit"), attach_to="logs")], + ), + Window("logs", panes=[Pane(run="tail -f x")]), + ], + ) + with pytest.raises(WorkspaceCompileError, match="cross-window floats"): + compile_workspace(ws) + + +def test_offline_build_with_float() -> None: + """A float-bearing workspace builds over the in-memory engine.""" + from libtmux.experimental.engines import ConcreteEngine + + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width=120, height=40), + ), + ], + ), + ], + ) + assert ws.build(ConcreteEngine(), preflight=False).ok + + +def test_events_for_new_pane() -> None: + """events_for emits a PaneCreated for a new-pane result.""" + from libtmux.experimental.ops import NewPane + from libtmux.experimental.ops._types import PaneId + from libtmux.experimental.workspace import PaneCreated + from libtmux.experimental.workspace.events import events_for + + op = NewPane(target=PaneId("%1")) + result = op.build_result(returncode=0, stdout=("%9",)) + assert events_for(op, result) == [PaneCreated("%9")] From b37dfc10ea16b3dbcfd08de4fe3c6bfce8ae5c54 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:31:29 -0500 Subject: [PATCH 100/223] Workspace(feat[compiler]): Cross-window floats via symbol table why: A floating pane could only attach to its host window; the compiler rejected attach_to pointing at another window. Cross-window overlays (e.g. a status float over a different window) need name-based references resolved across the whole spec. what: - Add a Symbols registry (Django app-registry style): each declared window publishes its first-pane SlotRef by name, so a float's attach_to resolves to any window declared anywhere (forward or backward) - Add _topo_order, a graphlib.TopologicalSorter primitive that orders the reference graph (floats after the windows they attach to) and rejects cycles -- the seam for future join-pane / cross-window ops - Compile floats in a second wire phase after every window exists, so cross-window SlotRefs always resolve; lift the cross-window raise and instead raise only for an undeclared attach_to name - Cover cross-window attach (forward ref), offline build, unknown attach_to, Symbols.resolve, and _topo_order ordering + cycle detection --- .../experimental/workspace/compiler.py | 173 +++++++++++++----- .../contract/test_workspace_floats.py | 63 ++++++- 2 files changed, 188 insertions(+), 48 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 78f48d528..59cd36eb0 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -24,6 +24,7 @@ from __future__ import annotations +import graphlib import typing as t from dataclasses import dataclass, field, replace @@ -48,13 +49,55 @@ from collections.abc import Mapping from libtmux.experimental.ops._types import SlotRef - from libtmux.experimental.workspace.ir import Window, Workspace + from libtmux.experimental.workspace.ir import FloatingPane, Window, Workspace class WorkspaceCompileError(ValueError): """A declared workspace cannot be lowered to Core operations.""" +class Symbols: + """A by-name registry of window references for cross-tree resolution. + + Mirrors Django's app registry / pending-operations pattern: a declared window + publishes its first-pane :class:`~..ops._types.SlotRef` under its name, and a + later reference (a floating pane's ``attach_to``) resolves against it -- so a + float can attach to any window declared anywhere in the workspace, forward or + backward in document order. + """ + + def __init__(self) -> None: + self._refs: dict[str, SlotRef] = {} + + def define(self, name: str, ref: SlotRef) -> None: + """Publish *ref* under *name*.""" + self._refs[name] = ref + + def resolve(self, name: str) -> SlotRef: + """Return the ref registered for *name*, or raise if it is undeclared.""" + try: + return self._refs[name] + except KeyError: + msg = f"floating pane attach_to={name!r} names no declared window" + raise WorkspaceCompileError(msg) from None + + +def _topo_order(dependencies: Mapping[t.Any, set[t.Any]]) -> list[t.Any]: + """Order nodes so each follows its dependencies; raise on a cycle. + + The cross-reference ordering engine (stdlib :mod:`graphlib`): a node is + emitted only after every node it depends on. Floating panes use it to land + after the windows they attach to; the same primitive sequences future + cross-window operations (join-pane, cross-window focus) correct-by-construction + and rejects declared cycles. + """ + try: + return list(graphlib.TopologicalSorter(dependencies).static_order()) + except graphlib.CycleError as exc: + msg = f"workspace has a reference cycle: {exc.args[1]}" + raise WorkspaceCompileError(msg) from exc + + @dataclass(frozen=True) class HostStep: """A host-side step interleaved by the runner (not a tmux operation). @@ -172,54 +215,88 @@ def _emit_pane_commands( ) -def _emit_floats( +def _emit_float( plan: LazyPlan, host_after: dict[int, list[HostStep]], pre: list[HostStep], ws: Workspace, - window: Window, - first_pane_ref: SlotRef, + host_window: Window, + target_ref: SlotRef, + fp: FloatingPane, ) -> None: - """Emit the window's floating-pane overlays (tmux 3.7 ``new-pane``). + """Emit one floating-pane overlay (tmux 3.7 ``new-pane``) over *target_ref*. - Overlays are created *after* the tiled panes and layout, target the window's - first pane, and are deliberately kept out of the split chain and the layout so - they never perturb the tiled topology. Cross-window ``attach_to`` (floating - over a *different* window) is not yet supported. + *target_ref* is the captured first pane of the window the float lands on (its + host window, or the ``attach_to`` window). *host_window* provides the + ``start_directory`` fallback and command context. The overlay is created + detached, so it never steals focus during the build. """ - for fp in window.floats: - if fp.attach_to is not None and fp.attach_to != window.name: - msg = ( - f"floating pane attach_to={fp.attach_to!r} targets another window; " - "cross-window floats are not yet supported" - ) - raise WorkspaceCompileError(msg) - geo = fp.geometry - float_ref = plan.add( - NewPane( - target=first_pane_ref, - width=geo.width, - height=geo.height, - x=geo.x, - y=geo.y, - zoom=geo.zoom, - empty=geo.empty, - style=geo.style, - active_border_style=geo.active_border_style, - inactive_border_style=geo.inactive_border_style, - message=geo.message, - start_directory=( - fp.pane.start_directory - or window.start_directory - or ws.start_directory - ), - environment=dict(fp.pane.environment) or None, - shell_command=fp.pane.shell, + geo = fp.geometry + float_ref = plan.add( + NewPane( + target=target_ref, + width=geo.width, + height=geo.height, + x=geo.x, + y=geo.y, + zoom=geo.zoom, + empty=geo.empty, + style=geo.style, + active_border_style=geo.active_border_style, + inactive_border_style=geo.inactive_border_style, + message=geo.message, + start_directory=( + fp.pane.start_directory + or host_window.start_directory + or ws.start_directory ), + environment=dict(fp.pane.environment) or None, + shell_command=fp.pane.shell, + ), + ) + _emit_pane_commands(plan, host_after, pre, ws, host_window, fp.pane, float_ref) + if fp.pane.focus: + plan.add(SelectPane(target=float_ref)) + + +def _emit_pending_floats( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + symbols: Symbols, + pending: list[tuple[FloatingPane, int, SlotRef]], +) -> None: + """Emit every window's floats after all windows exist (the wire phase). + + Each float depends on the window it lands on (its host, or its ``attach_to``); + a topological sort over that reference graph orders the floats after their + target windows and rejects cycles. ``attach_to`` resolves by name through + *symbols*, so a float can attach to a window declared anywhere. + """ + if not pending: + return + deps: dict[t.Any, set[t.Any]] = {} + by_node: dict[t.Any, tuple[FloatingPane, int, SlotRef]] = {} + for index, (fp, host_index, host_ref) in enumerate(pending): + float_node = ("float", index) + target_node = ( + ("window", fp.attach_to) + if fp.attach_to is not None + else ("host", host_index) ) - _emit_pane_commands(plan, host_after, pre, ws, window, fp.pane, float_ref) - if fp.pane.focus: - plan.add(SelectPane(target=float_ref)) + deps[float_node] = {target_node} + deps.setdefault(target_node, set()) + by_node[float_node] = (fp, host_index, host_ref) + for node in _topo_order(deps): + entry = by_node.get(node) + if entry is None: + continue + fp, host_index, host_ref = entry + target_ref = ( + symbols.resolve(fp.attach_to) if fp.attach_to is not None else host_ref + ) + _emit_float(plan, host_after, pre, ws, ws.windows[host_index], target_ref, fp) def _emit_window( @@ -231,10 +308,11 @@ def _emit_window( window_ref: SlotRef, first_pane_ref: SlotRef, ) -> None: - """Emit a window's options, panes, sends, layout, pane focus, and floats. + """Emit a window's options, panes, sends, layout, and pane focus. *window_ref* addresses the window (rename/options/layout); *first_pane_ref* is - the captured id of the window's first pane. + the captured id of the window's first pane. Floating overlays are emitted in a + second phase (see :func:`_emit_pending_floats`) once every window exists. """ for key, value in window.options.items(): plan.add(SetWindowOption(target=window_ref, option=key, value=value)) @@ -272,9 +350,6 @@ def _emit_window( for key, value in window.options_after.items(): plan.add(SetWindowOption(target=window_ref, option=key, value=value)) - # Floating overlays come last: after the tiled layout, excluded from it. - _emit_floats(plan, host_after, pre, ws, window, first_pane_ref) - def _creator_environment(window: Window) -> dict[str, str]: """Env for a window's *first* (implicit) pane, applied via its creator's ``-e``. @@ -337,6 +412,8 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: plan.add(SetOption(option=key, value=value, global_=True)) window_refs: list[SlotRef] = [] + symbols = Symbols() + pending_floats: list[tuple[FloatingPane, int, SlotRef]] = [] for index, window in enumerate(ws.windows): if index == 0: # Reuse the session's implicit first window via its captured ids. @@ -365,7 +442,13 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: window_ref = slot first_pane_ref = slot.pane window_refs.append(window_ref) + if window.name is not None: + symbols.define(window.name, first_pane_ref) _emit_window(plan, host_after, pre, ws, window, window_ref, first_pane_ref) + pending_floats.extend((fp, index, first_pane_ref) for fp in window.floats) + + # Wire phase: every window now exists, so floats attach to any of them by name. + _emit_pending_floats(plan, host_after, pre, ws, symbols, pending_floats) for index, window in enumerate(ws.windows): if window.focus: diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py index 08486f8c5..a91151780 100644 --- a/tests/experimental/contract/test_workspace_floats.py +++ b/tests/experimental/contract/test_workspace_floats.py @@ -15,6 +15,7 @@ analyze, compile_workspace, ) +from libtmux.experimental.workspace.compiler import Symbols, _topo_order def test_float_to_dict_omits_defaults() -> None: @@ -134,23 +135,79 @@ def test_compiler_new_pane_geometry_and_command() -> None: assert any(isinstance(op, SendKeys) and op.keys == "lazygit" for op in ops) -def test_cross_window_attach_to_raises() -> None: - """A float attaching to a different window is rejected (not yet supported).""" +def test_cross_window_float_attaches_to_later_window() -> None: + """A float on an early window can attach to a window declared later.""" ws = Workspace( name="dev", windows=[ Window( "editor", panes=[Pane(run="vim")], + # attach_to a window declared AFTER this one (forward reference) floats=[FloatingPane(pane=Pane(run="lazygit"), attach_to="logs")], ), Window("logs", panes=[Pane(run="tail -f x")]), ], ) - with pytest.raises(WorkspaceCompileError, match="cross-window floats"): + # Compiles (no raise) and the float op lands after both windows exist. + kinds = [op.kind for op in compile_workspace(ws).operations] + assert kinds.count("new_window") == 1 # 'logs' is created + assert kinds.index("new_pane") > kinds.index("new_window") + + +def test_cross_window_float_builds_offline() -> None: + """A cross-window float resolves its target and builds in-memory.""" + from libtmux.experimental.engines import ConcreteEngine + + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[FloatingPane(pane=Pane(run="htop"), attach_to="logs")], + ), + Window("logs", panes=[Pane(run="tail -f x")]), + ], + ) + assert ws.build(ConcreteEngine(), preflight=False).ok + + +def test_unknown_attach_to_raises() -> None: + """A float attaching to an undeclared window name is rejected.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[FloatingPane(pane=Pane(run="lazygit"), attach_to="nope")], + ), + ], + ) + with pytest.raises(WorkspaceCompileError, match="names no declared window"): compile_workspace(ws) +def test_symbols_resolve_unknown_raises() -> None: + """Symbols.resolve raises a clear error for an undeclared name.""" + symbols = Symbols() + with pytest.raises(WorkspaceCompileError, match="names no declared window"): + symbols.resolve("ghost") + + +def test_topo_order_orders_dependencies() -> None: + """_topo_order returns each node after the nodes it depends on.""" + order = _topo_order({"float": {"win"}, "win": set()}) + assert order.index("win") < order.index("float") + + +def test_topo_order_detects_cycle() -> None: + """_topo_order rejects a dependency cycle.""" + with pytest.raises(WorkspaceCompileError, match="reference cycle"): + _topo_order({"a": {"b"}, "b": {"a"}}) + + def test_offline_build_with_float() -> None: """A float-bearing workspace builds over the in-memory engine.""" from libtmux.experimental.engines import ConcreteEngine From 29a2ab01fd04e99abf0b2dc8221efdce1624a993 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:59:31 -0500 Subject: [PATCH 101/223] Query(feat): Add snapshot-backed live pane query why: The spine could list panes, but there was no ergonomic, chainable way to filter/order/project live panes the way QueryList powers server.panes -- the read half of the chainable-prototype DX. what: - Add panes() -> PaneQuery: an immutable, chainable query (filter/order_by/limit/all/first/map) over live panes - Resolve against a source that is either a TmuxEngine (a list-panes read) or a pure Sequence[PaneSnapshot]; filtering reuses QueryList so Django-style lookups (active=True, current_command="vim") work on snapshots - map() returns a MappedPaneQuery for pure data projections - Cover filter/order/limit/map/first/immutability, the empty-engine source, and a live engine-backed query scoped by window This is the live-object query layer (distinct from the declarative workspace IR); the command-building half (PaneRef + commands) is next. --- src/libtmux/experimental/query.py | 131 ++++++++++++++++++++++++++++++ tests/experimental/test_query.py | 104 ++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/libtmux/experimental/query.py create mode 100644 tests/experimental/test_query.py diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py new file mode 100644 index 000000000..9245f3a3e --- /dev/null +++ b/src/libtmux/experimental/query.py @@ -0,0 +1,131 @@ +"""A lazy, snapshot-backed query over live tmux panes. + +This is the live-object query layer (distinct from the declarative workspace IR): +``panes()`` builds an immutable query that resolves against a *snapshot* of the +live server -- either taken from an engine (a ``list-panes`` read) or supplied as +a pure sequence of :class:`~libtmux.experimental.models.snapshots.PaneSnapshot`. +Filtering reuses :class:`~libtmux._internal.query_list.QueryList`, so the same +Django-style lookups that power ``server.panes`` work here against snapshots. + +The query is pure and chainable; nothing runs until a terminal method +(:meth:`PaneQuery.all` / :meth:`PaneQuery.first`) resolves it against a source. + +Examples +-------- +>>> from libtmux.experimental.models.snapshots import PaneSnapshot +>>> rows = [ +... PaneSnapshot.from_format({"pane_id": "%1", "pane_index": "0", +... "pane_active": "1", "pane_current_command": "vim"}), +... PaneSnapshot.from_format({"pane_id": "%2", "pane_index": "1", +... "pane_active": "0", "pane_current_command": "zsh"}), +... ] +>>> panes().filter(active=True).map(lambda p: p.pane_id).all(rows) +('%1',) +>>> panes().order_by("pane_index").map(lambda p: p.pane_id).all(rows) +('%1', '%2') +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field, replace + +from libtmux._internal.query_list import QueryList +from libtmux.experimental.engines.base import TmuxEngine +from libtmux.experimental.ops import ListPanes, run + +if t.TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + + from libtmux.experimental.models.snapshots import PaneSnapshot + +#: A source of pane snapshots: an engine to read from, or pre-taken snapshots. +PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] +MappedT = t.TypeVar("MappedT") + + +def _snapshot_panes(source: PaneSource) -> tuple[PaneSnapshot, ...]: + """Resolve *source* into pane snapshots (read an engine, or pass through).""" + if isinstance(source, TmuxEngine): + return run(ListPanes(), source).panes + return tuple(source) + + +def _order_key(pane: PaneSnapshot, field_name: str) -> tuple[bool, t.Any]: + """Sort key for ``order_by`` that sorts missing (``None``) values last.""" + value = getattr(pane, field_name, None) + return (value is None, value) + + +@dataclass(frozen=True) +class PaneQuery: + """An immutable, chainable query over pane snapshots. + + Each method returns a new query; :meth:`all` / :meth:`first` resolve it + against a :data:`PaneSource`. + """ + + lookups: Mapping[str, t.Any] = field(default_factory=dict) + order: str | None = None + limit_count: int | None = None + + def filter(self, **lookups: t.Any) -> PaneQuery: + """Narrow by QueryList lookups (e.g. ``active=True``, ``pane_index=0``).""" + return replace(self, lookups={**self.lookups, **lookups}) + + def order_by(self, field_name: str) -> PaneQuery: + """Sort the results by a snapshot attribute (missing values last).""" + return replace(self, order=field_name) + + def limit(self, count: int) -> PaneQuery: + """Keep only the first *count* results.""" + return replace(self, limit_count=count) + + def all(self, source: PaneSource) -> tuple[PaneSnapshot, ...]: + """Resolve the query against *source* and return the matched snapshots.""" + rows: t.Any = QueryList(_snapshot_panes(source)) + if self.lookups: + rows = rows.filter(**self.lookups) + rows = list(rows) + if self.order is not None: + rows.sort(key=lambda pane: _order_key(pane, self.order)) + if self.limit_count is not None: + rows = rows[: self.limit_count] + return tuple(rows) + + def first(self, source: PaneSource) -> PaneSnapshot | None: + """Return the first matched snapshot, or ``None`` when none match.""" + rows = self.all(source) + return rows[0] if rows else None + + def map(self, fn: Callable[[PaneSnapshot], MappedT]) -> MappedPaneQuery[MappedT]: + """Project each matched snapshot through *fn* (a pure read projection).""" + return MappedPaneQuery(self, fn) + + +@dataclass(frozen=True) +class MappedPaneQuery(t.Generic[MappedT]): + """A :class:`PaneQuery` whose rows are projected through a function.""" + + query: PaneQuery + fn: Callable[[PaneSnapshot], MappedT] + + def all(self, source: PaneSource) -> tuple[MappedT, ...]: + """Resolve and project every matched snapshot.""" + return tuple(self.fn(pane) for pane in self.query.all(source)) + + def first(self, source: PaneSource) -> MappedT | None: + """Resolve and project the first matched snapshot, or ``None``.""" + first = self.query.first(source) + return self.fn(first) if first is not None else None + + +def panes() -> PaneQuery: + """Start a query over live panes. + + Examples + -------- + >>> panes().filter(active=True).order_by("pane_index").limit(1) + PaneQuery(lookups={'active': True}, order='pane_index', limit_count=1) + """ + return PaneQuery() diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py new file mode 100644 index 000000000..a59cfb5e0 --- /dev/null +++ b/tests/experimental/test_query.py @@ -0,0 +1,104 @@ +"""Tests for the live-pane query (``panes()``).""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.models.snapshots import PaneSnapshot +from libtmux.experimental.query import PaneQuery, panes + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _pane(pane_id: str, index: int, *, active: bool, command: str) -> PaneSnapshot: + return PaneSnapshot.from_format( + { + "pane_id": pane_id, + "pane_index": str(index), + "pane_active": "1" if active else "0", + "pane_current_command": command, + }, + ) + + +ROWS = ( + _pane("%1", 0, active=True, command="vim"), + _pane("%2", 1, active=False, command="zsh"), + _pane("%3", 2, active=False, command="vim"), +) + + +def test_panes_returns_query() -> None: + """panes() starts an empty, immutable query.""" + assert panes() == PaneQuery() + + +def test_filter_active() -> None: + """filter(active=True) keeps only the active pane.""" + assert [p.pane_id for p in panes().filter(active=True).all(ROWS)] == ["%1"] + + +def test_filter_lookup() -> None: + """Filter matches snapshot attributes (QueryList lookups).""" + result = panes().filter(current_command="vim").all(ROWS) + assert [p.pane_id for p in result] == ["%1", "%3"] + + +def test_order_by_and_limit() -> None: + """order_by sorts and limit truncates.""" + result = panes().order_by("pane_index").limit(2).all(ROWS) + assert [p.pane_id for p in result] == ["%1", "%2"] + + +def test_map_projection() -> None: + """Map projects each matched snapshot.""" + ids = panes().filter(current_command="vim").map(lambda p: p.pane_id).all(ROWS) + assert ids == ("%1", "%3") + + +def test_first_and_empty() -> None: + """First returns the first row, or None when nothing matches.""" + first = panes().order_by("pane_index").first(ROWS) + assert first is not None and first.pane_id == "%1" + assert panes().filter(current_command="nope").first(ROWS) is None + + +def test_mapped_first() -> None: + """A mapped query's first projects the first match (or None).""" + assert panes().filter(active=True).map(lambda p: p.pane_id).first(ROWS) == "%1" + assert panes().filter(active=False).map(lambda p: p.pane_id).first(()) is None + + +def test_query_is_immutable() -> None: + """Each builder method returns a new query; the original is unchanged.""" + base = panes() + narrowed = base.filter(active=True).order_by("pane_index").limit(1) + assert base == PaneQuery() + assert narrowed.lookups == {"active": True} + + +def test_empty_engine_source() -> None: + """A query resolves against an engine; the in-memory engine has no panes.""" + from libtmux.experimental.engines import ConcreteEngine + + assert panes().all(ConcreteEngine()) == () + + +def test_panes_live_against_engine(session: Session) -> None: + """panes() reads a live server through an engine and filters its panes.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import SplitWindow, run + from libtmux.experimental.ops._types import WindowId + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + + # Scope to our window (list-panes -a spans the whole server). + window_panes = panes().filter(window_id=window.window_id).all(engine) + assert len(window_panes) == 2 + active = panes().filter(window_id=window.window_id, active=True).all(engine) + assert len(active) == 1 + assert active[0].active is True From a4a8b6449923b016a1dd54076c51ef78191eafba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:05:25 -0500 Subject: [PATCH 102/223] Query(feat): Add per-pane command building that folds why: The query read live panes but could not act on them. The chainable prototype's headline DX is "do X to every pane matching Y in one tmux call" -- bulk commands over a filtered set, folded to a single dispatch. what: - Add PaneRef (a matched pane + a cmd namespace) and BoundPaneCommands (send_keys/resize/select/respawn/clear_history/kill), each recording a typed op into a shared plan - Add PaneQuery.commands(mapper) -> CommandPlan; CommandPlan.to_plan builds the ops against a snapshot (pure/inspectable) and CommandPlan.run reads the engine, builds, and dispatches folded (FoldingPlanner) by default - Layered entirely over LazyPlan/SlotRef/Planner -- no new execution path - Cover op-per-pane building, each command kind, the empty-match no-op, and a live folded run The bulk-command layer over the live query (G18); fluent split/forward handles remain a possible follow-up. --- src/libtmux/experimental/query.py | 153 +++++++++++++++++++++++++++++- tests/experimental/test_query.py | 65 +++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index 9245f3a3e..05d752fa9 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -32,12 +32,26 @@ from libtmux._internal.query_list import QueryList from libtmux.experimental.engines.base import TmuxEngine -from libtmux.experimental.ops import ListPanes, run +from libtmux.experimental.ops import ( + ClearHistory, + FoldingPlanner, + KillPane, + LazyPlan, + ListPanes, + PaneId, + ResizePane, + RespawnPane, + SelectPane, + SendKeys, + run, +) if t.TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence from libtmux.experimental.models.snapshots import PaneSnapshot + from libtmux.experimental.ops import Planner, PlanResult + from libtmux.experimental.ops._types import SlotRef #: A source of pane snapshots: an engine to read from, or pre-taken snapshots. PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] @@ -102,6 +116,15 @@ def map(self, fn: Callable[[PaneSnapshot], MappedT]) -> MappedPaneQuery[MappedT] """Project each matched snapshot through *fn* (a pure read projection).""" return MappedPaneQuery(self, fn) + def commands(self, mapper: Callable[[PaneRef], t.Any]) -> CommandPlan: + """Build commands for each matched pane (folds to one dispatch on run). + + *mapper* receives a :class:`PaneRef` per matched pane and records + operations through ``ref.cmd`` (e.g. ``ref.cmd.send_keys("clear")``); the + returned :class:`CommandPlan` is pure until :meth:`CommandPlan.run`. + """ + return CommandPlan(self, mapper) + @dataclass(frozen=True) class MappedPaneQuery(t.Generic[MappedT]): @@ -120,6 +143,134 @@ def first(self, source: PaneSource) -> MappedT | None: return self.fn(first) if first is not None else None +@dataclass(frozen=True) +class BoundPaneCommands: + """Pane commands bound to a target, recording into a shared plan. + + Each method appends a typed operation targeting the bound pane to the plan + and returns its :class:`~..ops._types.SlotRef`, so commands compose and the + plan folds to a single tmux dispatch. + """ + + plan: LazyPlan + target: PaneId + + def send_keys( + self, + keys: str, + *, + enter: bool = True, + suppress_history: bool = False, + ) -> SlotRef: + """Send *keys* to the pane (submitted with Enter unless ``enter=False``).""" + return self.plan.add( + SendKeys( + target=self.target, + keys=keys, + enter=enter, + suppress_history=suppress_history, + ), + ) + + def resize(self, *, height: int | None = None, width: int | None = None) -> SlotRef: + """Resize the pane to *height* rows and/or *width* columns.""" + return self.plan.add( + ResizePane(target=self.target, height=height, width=width), + ) + + def select(self, *, zoom: bool = False) -> SlotRef: + """Make the pane active (optionally zooming it).""" + return self.plan.add(SelectPane(target=self.target, zoom=zoom)) + + def respawn(self, *, kill: bool = False, shell: str | None = None) -> SlotRef: + """Respawn the pane's command (``kill=True`` replaces a live one).""" + return self.plan.add( + RespawnPane(target=self.target, kill=kill, shell=shell), + ) + + def clear_history(self) -> SlotRef: + """Clear the pane's scrollback history.""" + return self.plan.add(ClearHistory(target=self.target)) + + def kill(self) -> SlotRef: + """Kill the pane.""" + return self.plan.add(KillPane(target=self.target)) + + +@dataclass(frozen=True) +class PaneRef: + """A matched pane plus a ``cmd`` namespace that records into a plan.""" + + pane: PaneSnapshot + plan: LazyPlan + + @property + def pane_id(self) -> str: + """The pane's id.""" + return self.pane.pane_id + + @property + def active(self) -> bool: + """Whether the pane is active in its window.""" + return self.pane.active + + @property + def cmd(self) -> BoundPaneCommands: + """Pane commands bound to this pane (recorded into the plan).""" + return BoundPaneCommands(self.plan, PaneId(self.pane.pane_id)) + + +@dataclass(frozen=True) +class CommandPlan: + """A pending bulk-command build: a query plus a per-pane command mapper. + + Pure until resolved -- :meth:`to_plan` records the operations against a source + snapshot; :meth:`run` reads the source, builds, and dispatches (folding to a + single tmux call by default). + """ + + query: PaneQuery + mapper: Callable[[PaneRef], t.Any] + + def to_plan(self, source: PaneSource) -> LazyPlan: + """Resolve the matched panes and record each one's commands into a plan. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import PaneSnapshot + >>> rows = [PaneSnapshot.from_format({"pane_id": "%1", "pane_active": "1"})] + >>> cp = panes().filter(active=True).commands( + ... lambda p: p.cmd.send_keys("clear") + ... ) + >>> plan = cp.to_plan(rows) + >>> [op.kind for op in plan.operations] + ['send_keys'] + >>> plan.operations[0].render() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + plan = LazyPlan() + for pane in self.query.all(source): + self.mapper(PaneRef(pane, plan)) + return plan + + def run( + self, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Read panes from *engine*, build the plan, and dispatch it. + + Folds the per-pane commands into a single tmux dispatch by default; pass + *planner* to override. + """ + plan = self.to_plan(engine) + return plan.execute( + engine, version=version, planner=planner or FoldingPlanner() + ) + + def panes() -> PaneQuery: """Start a query over live panes. diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index a59cfb5e0..e3a7c8ec5 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -85,6 +85,71 @@ def test_empty_engine_source() -> None: assert panes().all(ConcreteEngine()) == () +def test_commands_to_plan_builds_one_op_per_pane() -> None: + """commands(mapper) records each matched pane's op into a plan.""" + plan = ( + panes() + .filter(current_command="vim") + .commands(lambda p: p.cmd.send_keys("clear")) + .to_plan(ROWS) + ) + assert [op.kind for op in plan.operations] == ["send_keys", "send_keys"] + assert plan.operations[0].render() == ("send-keys", "-t", "%1", "clear", "Enter") + assert plan.operations[1].render() == ("send-keys", "-t", "%3", "clear", "Enter") + + +def test_bound_pane_commands_record_each_kind() -> None: + """The cmd namespace records the expected operation kinds.""" + plan = ( + panes() + .filter(active=True) + .commands( + lambda p: ( + p.cmd.resize(height=20), + p.cmd.select(zoom=True), + p.cmd.clear_history(), + ), + ) + .to_plan(ROWS) + ) + assert [op.kind for op in plan.operations] == [ + "resize_pane", + "select_pane", + "clear_history", + ] + + +def test_commands_empty_match_is_noop() -> None: + """commands() over no matches builds an empty plan.""" + plan = ( + panes() + .filter(current_command="nope") + .commands(lambda p: p.cmd.send_keys("x")) + .to_plan(ROWS) + ) + assert list(plan.operations) == [] + + +def test_commands_run_live(session: Session) -> None: + """commands().run reads live panes, builds, and dispatches (folded).""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import SplitWindow, run + from libtmux.experimental.ops._types import WindowId + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + + result = ( + panes() + .filter(window_id=window.window_id) + .commands(lambda p: p.cmd.send_keys("true")) + .run(engine) + ) + assert result.ok + + def test_panes_live_against_engine(session: Session) -> None: """panes() reads a live server through an engine and filters its panes.""" from libtmux.experimental.engines import SubprocessEngine From 5409de5eda7026595dca678cc04667e2b76b96bc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:14:04 -0500 Subject: [PATCH 103/223] Facade(feat[pane]): Add new_pane floating parity why: The typed pane facades exposed split() but not new_pane(), so floating panes were reachable from the ops/workspace tiers but not the eager/lazy/async handles that are the modern facade surface. what: - Add new_pane() to EagerPane (live handle), LazyPane (deferred handle over the plan), and AsyncPane (awaited live handle), each returning a handle to the created floating pane - Share a _new_pane_op builder across the three facades so the floating geometry vocabulary (width/height/x/y/zoom/empty/styles/...) stays in one place - Cover eager/lazy/async new_pane (live handle, recorded op + render, awaited handle) --- src/libtmux/experimental/facade/pane.py | 172 ++++++++++++++++++ tests/experimental/facade/test_pane_facade.py | 41 +++++ 2 files changed, 213 insertions(+) diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/facade/pane.py index 61aec8d23..9dba79910 100644 --- a/src/libtmux/experimental/facade/pane.py +++ b/src/libtmux/experimental/facade/pane.py @@ -23,6 +23,7 @@ from libtmux.experimental.ops import ( CapturePane, + NewPane, SendKeys, SplitWindow, arun, @@ -31,12 +32,52 @@ from libtmux.experimental.ops._types import PaneId if t.TYPE_CHECKING: + from collections.abc import Mapping + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops._types import Target from libtmux.experimental.ops.plan import LazyPlan from libtmux.experimental.ops.results import CapturePaneResult, Result +def _new_pane_op( + target: Target, + *, + width: int | str | None, + height: int | str | None, + x: int | str | None, + y: int | str | None, + zoom: bool, + detach: bool, + empty: bool, + start_directory: str | None, + environment: Mapping[str, str] | None, + style: str | None, + active_border_style: str | None, + inactive_border_style: str | None, + message: str | None, + shell_command: str | None, +) -> NewPane: + """Build a :class:`~..ops.NewPane` for *target* (shared by the facades).""" + return NewPane( + target=target, + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ) + + @dataclass(frozen=True) class EagerPane: """A live pane handle bound to an engine; methods execute immediately. @@ -57,6 +98,9 @@ class EagerPane: >>> child = pane.split(horizontal=True) >>> child.pane_id '%1' + >>> floating = pane.new_pane(width=80, height=20) + >>> floating.pane_id + '%2' >>> isinstance(pane.capture().lines, tuple) True """ @@ -87,6 +131,50 @@ def split( assert result.new_pane_id is not None return EagerPane(self.engine, result.new_pane_id, self.version) + def new_pane( + self, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + detach: bool = True, + empty: bool = False, + start_directory: str | None = None, + environment: Mapping[str, str] | None = None, + style: str | None = None, + active_border_style: str | None = None, + inactive_border_style: str | None = None, + message: str | None = None, + shell_command: str | None = None, + ) -> EagerPane: + """Create a floating pane (tmux 3.7+) and return a live handle to it.""" + result = run( + _new_pane_op( + PaneId(self.pane_id), + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + def send_keys(self, keys: str, *, enter: bool = False) -> Result: """Send keys to this pane; return the typed result.""" return run( @@ -155,6 +243,46 @@ def split( ) return LazyPane(self.plan, slot) + def new_pane( + self, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + detach: bool = True, + empty: bool = False, + start_directory: str | None = None, + environment: Mapping[str, str] | None = None, + style: str | None = None, + active_border_style: str | None = None, + inactive_border_style: str | None = None, + message: str | None = None, + shell_command: str | None = None, + ) -> LazyPane: + """Record a floating-pane creation; return a deferred handle to it.""" + slot = self.plan.add( + _new_pane_op( + self.ref, + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ), + ) + return LazyPane(self.plan, slot) + def send_keys(self, keys: str, *, enter: bool = False) -> LazyPane: """Record a send-keys against this handle; return self for chaining.""" self.plan.add(SendKeys(target=self.ref, keys=keys, enter=enter)) @@ -212,6 +340,50 @@ async def split( assert result.new_pane_id is not None return AsyncPane(self.engine, result.new_pane_id, self.version) + async def new_pane( + self, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + detach: bool = True, + empty: bool = False, + start_directory: str | None = None, + environment: Mapping[str, str] | None = None, + style: str | None = None, + active_border_style: str | None = None, + inactive_border_style: str | None = None, + message: str | None = None, + shell_command: str | None = None, + ) -> AsyncPane: + """Create a floating pane (tmux 3.7+) and return a live async handle.""" + result = await arun( + _new_pane_op( + PaneId(self.pane_id), + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + async def send_keys(self, keys: str, *, enter: bool = False) -> Result: """Send keys to this pane; return the typed result.""" return await arun( diff --git a/tests/experimental/facade/test_pane_facade.py b/tests/experimental/facade/test_pane_facade.py index d5508691a..b74ed3daf 100644 --- a/tests/experimental/facade/test_pane_facade.py +++ b/tests/experimental/facade/test_pane_facade.py @@ -48,6 +48,47 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") +def test_eager_new_pane_returns_live_pane() -> None: + """EagerPane.new_pane creates a floating pane and returns a live handle.""" + pane = EagerPane(ConcreteEngine(), "%0") + floating = pane.new_pane(width=80, height=20, x=5, y=3) + assert isinstance(floating, EagerPane) + assert floating.pane_id == "%1" + + +def test_lazy_new_pane_records_new_pane_op() -> None: + """LazyPane.new_pane records a new-pane op (deferred) with its geometry.""" + plan = LazyPlan() + LazyPane(plan, PaneId("%0")).new_pane(width="50%", height="40%") + assert plan.operations[0].kind == "new_pane" + assert plan.operations[0].render() == ( + "new-pane", + "-t", + "%0", + "-x50%", + "-y40%", + "-d", + "-P", + "-F", + "#{pane_id}", + ) + + +def test_async_new_pane_returns_live_pane() -> None: + """AsyncPane.new_pane creates a floating pane and returns a live handle.""" + import asyncio + + from libtmux.experimental.engines import AsyncConcreteEngine + from libtmux.experimental.facade import AsyncPane + + async def main() -> str: + pane = AsyncPane(AsyncConcreteEngine(), "%0") + floating = await pane.new_pane(width=80, height=20) + return floating.pane_id + + assert asyncio.run(main()) == "%1" + + def test_same_operation_backs_both_facades() -> None: """Eager and lazy facades render the identical underlying operation argv.""" eager_engine = ConcreteEngine() From c7ca2133386cabe6f3de10e8ab07f4b175710476 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:22:12 -0500 Subject: [PATCH 104/223] Mcp(feat[pane]): Add curated new_pane floating tool why: NewPane auto-projects as op_new_pane, but that surface is hidden behind the per-op tag; agents reach for the curated, always-visible vocabulary. Floating panes had no curated tool, so they were effectively undiscoverable. what: - Add anew_pane to the pane vocabulary (async-first) and new_pane = synced(anew_pane); FastMCP derives the input schema from the signature and the output schema from PaneResult - Register ("new_pane", "mutating") in the adapter _TOOLS table; export anew_pane/new_pane from the vocabulary and new_pane from the mcp facade - The tool description notes the tmux 3.7+ requirement - Cover the curated new_pane tool over the in-memory engine Surfacing whole-op min_version into the auto-projected op_* schema (G8) remains a small follow-up. --- src/libtmux/experimental/mcp/__init__.py | 2 + .../experimental/mcp/fastmcp_adapter.py | 1 + .../experimental/mcp/vocabulary/__init__.py | 4 ++ .../experimental/mcp/vocabulary/pane.py | 41 +++++++++++++++++++ tests/experimental/mcp/test_vocabulary.py | 9 ++++ 5 files changed, 57 insertions(+) diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 4a66441ca..03e33ab6f 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -56,6 +56,7 @@ list_panes, list_sessions, list_windows, + new_pane, rename_session, rename_window, select_layout, @@ -292,6 +293,7 @@ def main(argv: Sequence[str] | None = None) -> None: "list_sessions", "list_windows", "main", + "new_pane", "preview_plan", "rename_session", "rename_window", diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 6c448550c..19589017a 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -45,6 +45,7 @@ ("create_session", "mutating"), ("create_window", "mutating"), ("split_pane", "mutating"), + ("new_pane", "mutating"), ("send_input", "mutating"), ("capture_pane", "readonly"), ("capture_active_pane", "readonly"), diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py index f39e93832..bc5e26b45 100644 --- a/src/libtmux/experimental/mcp/vocabulary/__init__.py +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -65,6 +65,7 @@ ajoin_pane, akill_pane, alist_panes, + anew_pane, aresize_pane, aresolve_relative_pane, arespawn_pane, @@ -83,6 +84,7 @@ join_pane, kill_pane, list_panes, + new_pane, resize_pane, resolve_relative_pane, respawn_pane, @@ -165,6 +167,7 @@ "alist_sessions", "alist_windows", "amove_window", + "anew_pane", "apaste_buffer", "arename_session", "arename_window", @@ -204,6 +207,7 @@ "list_sessions", "list_windows", "move_window", + "new_pane", "paste_buffer", "rename_session", "rename_window", diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 032312835..8e14a6db2 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -64,6 +64,7 @@ JoinPane, KillPane, ListPanes, + NewPane, ResizePane, RespawnPane, SelectPane, @@ -108,6 +109,45 @@ async def asplit_pane( return PaneResult(pane_id=result.new_pane_id or "") +async def anew_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + empty: bool = False, + start_directory: str | None = None, + shell_command: str | None = None, + version: str | None = None, +) -> PaneResult: + """Create a floating pane over *target*'s window (requires tmux 3.7+). + + Floating panes overlay the layout (popup-style, but non-modal) instead of + tiling. Geometry is absolute: ``width``/``height`` size it and ``x``/``y`` + position it, each in cells or an ``"N%"`` string. Returns the new pane id. + """ + result = await arun( + NewPane( + target=resolve_target(target), + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + empty=empty, + start_directory=start_directory, + shell_command=shell_command, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneResult(pane_id=result.new_pane_id or "") + + async def asend_input( engine: AsyncTmuxEngine, target: str | Target, @@ -593,6 +633,7 @@ async def _raise_no_neighbour( split_pane = synced(asplit_pane) +new_pane = synced(anew_pane) send_input = synced(asend_input) capture_pane = synced(acapture_pane) capture_active_pane = synced(acapture_active_pane) diff --git a/tests/experimental/mcp/test_vocabulary.py b/tests/experimental/mcp/test_vocabulary.py index 790112953..c0c4ad372 100644 --- a/tests/experimental/mcp/test_vocabulary.py +++ b/tests/experimental/mcp/test_vocabulary.py @@ -18,6 +18,7 @@ list_panes, list_sessions, list_windows, + new_pane, rename_window, send_input, split_pane, @@ -51,6 +52,14 @@ def test_create_window_then_split() -> None: assert pane.pane_id.startswith("%") +def test_new_pane_creates_floating_pane() -> None: + """new_pane creates a floating pane and returns its id (in-memory).""" + engine = ConcreteEngine() + session = create_session(engine, name="dev") + pane = new_pane(engine, session.first_pane_id or "%1", width=80, height=20) + assert pane.pane_id.startswith("%") + + def test_send_input_is_fire_and_forget() -> None: """send_input runs without returning a value (and without raising).""" send_input(ConcreteEngine(), "%1", "echo hi", enter=True) From 1062af24a5496d56ad04f6eb13c10931faca1274 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:30:24 -0500 Subject: [PATCH 105/223] Mcp(feat[registry]): Surface whole-op min_version in schema why: The descriptor projected per-flag version gates but not a whole operation's min_version, so the auto-projected op_new_pane advertised no tmux requirement -- an agent on an older tmux would hit a raw VersionUnsupported instead of a documented gate. what: - Add ToolDescriptor.min_version, populated from OpSpec.min_version - Append "Requires tmux >= X.Y." to the projected tool description when a whole-command gate is set - Cover op_new_pane surfacing min_version 3.7 (and an ungated op not) --- src/libtmux/experimental/mcp/descriptor.py | 4 ++++ src/libtmux/experimental/mcp/registry.py | 8 +++++++- tests/experimental/mcp/test_mcp_projection.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/mcp/descriptor.py b/src/libtmux/experimental/mcp/descriptor.py index 929a9c414..c03cc016c 100644 --- a/src/libtmux/experimental/mcp/descriptor.py +++ b/src/libtmux/experimental/mcp/descriptor.py @@ -77,6 +77,9 @@ class ToolDescriptor: MCP-style hints derived from safety/effects. operation_cls The operation class :meth:`build` instantiates. + min_version + Minimum tmux version the whole operation requires, if any (surfaced in + the tool description so agents see the gate before dispatch). """ name: str @@ -92,6 +95,7 @@ class ToolDescriptor: version_gates: Mapping[str, str] effects: Mapping[str, t.Any] operation_cls: type[Operation[t.Any]] + min_version: str | None = None def input_schema(self) -> dict[str, t.Any]: """Render the JSON schema for this tool's input object.""" diff --git a/src/libtmux/experimental/mcp/registry.py b/src/libtmux/experimental/mcp/registry.py index 6f153d35e..1e1616989 100644 --- a/src/libtmux/experimental/mcp/registry.py +++ b/src/libtmux/experimental/mcp/registry.py @@ -127,10 +127,15 @@ def descriptors(self) -> list[ToolDescriptor]: def _build(self, spec: OpSpec) -> ToolDescriptor: """Project one ``OpSpec`` into a tool descriptor.""" + description = _summary(spec.operation_cls.__doc__) + if spec.min_version: + # Surface the whole-command version gate so an agent on an older tmux + # sees the requirement up front instead of a raw VersionUnsupported. + description = f"{description} Requires tmux >= {spec.min_version}." return ToolDescriptor( name=spec.kind, title=spec.kind.replace("_", " ").title(), - description=_summary(spec.operation_cls.__doc__), + description=description, scope=spec.scope, safety=spec.safety, params=self._params(spec), @@ -141,6 +146,7 @@ def _build(self, spec: OpSpec) -> ToolDescriptor: version_gates=dict(spec.flag_version_map), effects=dataclasses.asdict(spec.effects), operation_cls=spec.operation_cls, + min_version=spec.min_version, ) def _params(self, spec: OpSpec) -> dict[str, ParamDescriptor]: diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 237b6d0d6..4bb50d2e1 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -51,6 +51,18 @@ def test_split_window_descriptor_shape() -> None: assert desc.params["horizontal"].is_required is False +def test_min_version_surfaced_on_descriptor() -> None: + """A whole-op min_version projects onto the descriptor and its description.""" + desc = OperationToolRegistry().descriptor("new_pane") + assert desc.min_version == "3.7" + assert "tmux >= 3.7" in desc.description + + +def test_ungated_op_has_no_min_version() -> None: + """An op without a whole-command gate carries no min_version.""" + assert OperationToolRegistry().descriptor("split_window").min_version is None + + def test_readonly_op_annotation() -> None: """A readonly operation projects a readOnlyHint annotation + tag.""" desc = OperationToolRegistry().descriptor("has_session") From 20388db0ff725ff9fe500858089cd03e76d7b9d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:19:06 -0500 Subject: [PATCH 106/223] Mcp(fix[prompts]): wait_for_output uses target= why: wait_for_output takes target=, not pane=; a recipe emitting pane= would fail FastMCP schema validation before dispatch. what: - Replace pane= with target= in run_and_wait, diagnose_failing_pane and interrupt_gracefully - Add parametrized regression test asserting target= usage --- src/libtmux/experimental/mcp/prompts.py | 6 ++-- tests/experimental/mcp/test_prompts.py | 40 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/mcp/prompts.py b/src/libtmux/experimental/mcp/prompts.py index 48c922169..582826ba5 100644 --- a/src/libtmux/experimental/mcp/prompts.py +++ b/src/libtmux/experimental/mcp/prompts.py @@ -21,7 +21,7 @@ def run_and_wait(command: str, pane_id: str, timeout: float = 60.0) -> str: pane to settle and inspect the result: 1. send_input(target={pane_id!r}, keys={command!r}, enter=True) -2. wait_for_output(pane={pane_id!r}, timeout={timeout}) -- folds the live output +2. wait_for_output(target={pane_id!r}, timeout={timeout}) -- folds the live output and returns when the pane goes quiet (needle-free: no regex, no sentinel). 3. Read done.pane_dead / done.pane_dead_status (exit code) and captured_text. "Settled" means output stopped, not that the command succeeded -- check the @@ -37,7 +37,7 @@ def diagnose_failing_pane(pane_id: str) -> str: 1. capture_pane(target={pane_id!r}) to read the scrollback (the active prompt and most recent output are at the bottom). -2. If the pane is still producing output, wait_for_output(pane={pane_id!r}) until +2. If the pane is still producing output, wait_for_output(target={pane_id!r}) until it settles, then capture again. 3. Identify the last command that ran (the prompt line and the line above it) and the last non-empty output line. @@ -70,7 +70,7 @@ def interrupt_gracefully(pane_id: str) -> str: 1. send_input(target={pane_id!r}, keys="C-c", enter=False) -- tmux interprets C-c as SIGINT. -2. wait_for_output(pane={pane_id!r}, timeout=5.0) -- wait for the pane to settle +2. wait_for_output(target={pane_id!r}, timeout=5.0) -- wait for the pane to settle back at a shell prompt. 3. If it does not settle, the process is ignoring SIGINT. Stop and ask the caller how to proceed -- do NOT escalate automatically to C-\ (SIGQUIT) or kill.""" diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py index 2d097c621..341d8fa84 100644 --- a/tests/experimental/mcp/test_prompts.py +++ b/tests/experimental/mcp/test_prompts.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import typing as t import pytest @@ -62,3 +63,42 @@ def test_prompt_bodies_use_engine_ops_vocabulary() -> None: assert "send_input" in run_and_wait("ls", "%1") assert "split_pane" in build_dev_workspace("dev") assert "wait_for_output" in interrupt_gracefully("%1") + + +class WaitForOutputCase(t.NamedTuple): + """A prompt body that calls ``wait_for_output``.""" + + test_id: str + body: str + + +def _wait_for_output_bodies() -> tuple[WaitForOutputCase, ...]: + from libtmux.experimental.mcp.prompts import ( + diagnose_failing_pane, + interrupt_gracefully, + run_and_wait, + ) + + return ( + WaitForOutputCase("run_and_wait", run_and_wait("ls", "%1")), + WaitForOutputCase("diagnose_failing_pane", diagnose_failing_pane("%1")), + WaitForOutputCase("interrupt_gracefully", interrupt_gracefully("%1")), + ) + + +_WAIT_FOR_OUTPUT_CASES = _wait_for_output_bodies() + + +@pytest.mark.parametrize( + "case", + _WAIT_FOR_OUTPUT_CASES, + ids=[c.test_id for c in _WAIT_FOR_OUTPUT_CASES], +) +def test_wait_for_output_uses_target_param(case: WaitForOutputCase) -> None: + """Prompts must call ``wait_for_output`` with ``target=`` (its real param). + + The tool signature is ``wait_for_output(target=..., ...)``; ``pane=`` is not a + parameter, so a recipe emitting it would fail FastMCP schema validation. + """ + assert "wait_for_output(target=" in case.body + assert "wait_for_output(pane=" not in case.body From b0be052a1df7cacdf080367059e2b4452e2a0146 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:24:10 -0500 Subject: [PATCH 107/223] Workspace(fix[analyze]): Reject bad command items why: A non-str/non-Mapping shell_command item (int, float, list) was silently dropped, hiding malformed config from the user. what: - Raise TypeError on unsupported shell_command items, matching the module's existing "unsupported pane config" error style - Keep None tolerated (a blank mixed with commands, tmuxp parity) - Add parametrized tests for rejected and normalized items --- .../experimental/workspace/analyzer.py | 5 ++ .../contract/test_workspace_analyze.py | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/experimental/contract/test_workspace_analyze.py diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 9eda00977..45d11f4aa 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -185,6 +185,11 @@ def _shell_commands(value: t.Any) -> tuple[str | Command, ...]: out.append(item) elif isinstance(item, collections.abc.Mapping): out.append(_command(item)) + elif item is None: + continue # a None mixed with real commands is a blank (tmuxp parity) + else: + msg = f"unsupported shell_command item: {item!r}" + raise TypeError(msg) return tuple(out) diff --git a/tests/experimental/contract/test_workspace_analyze.py b/tests/experimental/contract/test_workspace_analyze.py new file mode 100644 index 000000000..72b990d6a --- /dev/null +++ b/tests/experimental/contract/test_workspace_analyze.py @@ -0,0 +1,61 @@ +"""Tests for ``analyze`` shell_command normalization.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.workspace import analyze + + +def _config(shell_command: t.Any) -> dict[str, t.Any]: + """Build a minimal one-window/one-pane config carrying *shell_command*.""" + return { + "session_name": "s", + "windows": [{"panes": [{"shell_command": shell_command}]}], + } + + +class BadCase(t.NamedTuple): + """A shell_command list with an item ``analyze`` must reject.""" + + test_id: str + shell_command: t.Any + + +BAD_CASES = ( + BadCase("sole_int", [123]), + BadCase("int_mixed_with_command", ["echo hi", 123]), + BadCase("float", [1.5]), + BadCase("nested_list", [["echo"]]), +) + + +@pytest.mark.parametrize("case", BAD_CASES, ids=[c.test_id for c in BAD_CASES]) +def test_unsupported_shell_command_item_raises(case: BadCase) -> None: + """A non-str/non-Mapping item raises rather than silently vanishing.""" + with pytest.raises(TypeError, match="unsupported shell_command item"): + analyze(_config(case.shell_command)) + + +class OkCase(t.NamedTuple): + """A shell_command list ``analyze`` normalizes without raising.""" + + test_id: str + shell_command: t.Any + expected: tuple[str, ...] + + +OK_CASES = ( + OkCase("plain_strings", ["a", "b"], ("a", "b")), + OkCase("none_mixed_is_dropped", ["echo hi", None], ("echo hi",)), + OkCase("sole_none_is_blank", [None], ()), +) + + +@pytest.mark.parametrize("case", OK_CASES, ids=[c.test_id for c in OK_CASES]) +def test_supported_shell_command_items_normalize(case: OkCase) -> None: + """Valid items normalize; a None mixed with commands is dropped (tmuxp parity).""" + ws = analyze(_config(case.shell_command)) + assert ws.windows[0].panes[0].run == case.expected From cb2842661297b2cc1de844c1312ed2a39d0d1e26 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:32:35 -0500 Subject: [PATCH 108/223] Workspace(fix[env]): Inherit window env in splits why: A split pane with its own environment dropped the window environment entirely, contradicting the documented "inherited by its panes" contract and the first pane's merged creator env. what: - Merge window + pane environment for split-window -e (pane wins) - Correct the creator-env test to assert the merged split env - Add parametrized tests for window/pane env precedence --- .../experimental/workspace/compiler.py | 4 +- ..._async_control_engine_workspace_builder.py | 6 +- .../contract/test_workspace_environment.py | 61 +++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/experimental/contract/test_workspace_environment.py diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 59cd36eb0..13fe27b1f 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -332,9 +332,7 @@ def _emit_window( or window.start_directory or ws.start_directory ), - environment=( - dict(pane.environment) or dict(window.environment) or None - ), + environment={**window.environment, **pane.environment} or None, shell=pane.shell or window.window_shell, ), ) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 3715faa01..3a427c48c 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -546,7 +546,7 @@ def test_compile_folds_first_pane_env_into_creator() -> None: Window 0 reuses the session's implicit pane, so its env -- and its first pane's -- folds into ``new-session -e``; window 2..N fold into ``new-window - -e``. A *split* pane carries its own ``-e``. + -e``. A *split* pane inherits the window env, merged with its own ``-e``. """ ws = Workspace( name="ws-env", @@ -570,8 +570,8 @@ def test_compile_folds_first_pane_env_into_creator() -> None: assert new_session.environment == {"WIN_ENV": "w", "PANE_ENV": "p"} # window 1 folds into new-window -e assert new_window.environment == {"W2": "x"} - # a split pane carries its own env (falling back to the window's) - assert split.environment == {"SPLIT_ENV": "s"} + # a split pane inherits the window env, merged with its own (pane wins) + assert split.environment == {"WIN_ENV": "w", "SPLIT_ENV": "s"} def test_compile_first_window_start_directory_drives_new_session() -> None: diff --git a/tests/experimental/contract/test_workspace_environment.py b/tests/experimental/contract/test_workspace_environment.py new file mode 100644 index 000000000..d425db396 --- /dev/null +++ b/tests/experimental/contract/test_workspace_environment.py @@ -0,0 +1,61 @@ +"""Tests for environment merging when compiling a workspace to operations.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import SplitWindow +from libtmux.experimental.workspace import Pane, Window, Workspace, compile_workspace + + +class EnvCase(t.NamedTuple): + """A window/split-pane env pair and the merged env the split op should carry.""" + + test_id: str + window_env: dict[str, str] + pane_env: dict[str, str] + expected: dict[str, str] | None + + +ENV_CASES = ( + EnvCase("window_only", {"TERM": "xterm"}, {}, {"TERM": "xterm"}), + EnvCase("pane_only", {}, {"DEBUG": "1"}, {"DEBUG": "1"}), + EnvCase( + "window_and_pane_merge", + {"TERM": "xterm"}, + {"DEBUG": "1"}, + {"TERM": "xterm", "DEBUG": "1"}, + ), + EnvCase("pane_overrides_window", {"K": "win"}, {"K": "pane"}, {"K": "pane"}), + EnvCase("none_when_both_empty", {}, {}, None), +) + + +@pytest.mark.parametrize("case", ENV_CASES, ids=[c.test_id for c in ENV_CASES]) +def test_split_pane_environment_merges_window_and_pane(case: EnvCase) -> None: + """A split pane's env merges the window env with its own (the pane wins). + + The window env is "inherited by its panes", so a split pane carrying its own + env must not discard the window env -- it merges, matching the first pane's + creator env. + """ + ws = Workspace( + name="s", + windows=[ + Window( + "w", + environment=case.window_env, + panes=[ + Pane(run="vim"), + Pane(run="htop", environment=case.pane_env), + ], + ), + ], + ) + splits = [ + op for op in compile_workspace(ws).operations if isinstance(op, SplitWindow) + ] + assert len(splits) == 1 + assert splits[0].environment == case.expected From 6d043d6559b709269d4bda7204b6641d848498da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 09:52:12 -0500 Subject: [PATCH 109/223] docs(CHANGES) Note on updates --- CHANGES | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGES b/CHANGES index 76aac6b79..969f1486d 100644 --- a/CHANGES +++ b/CHANGES @@ -103,6 +103,41 @@ fold boundaries that a fold never crosses. Pass a {class}`~libtmux.experimental.ops.planner.SequentialPlanner` to ``build`` for one legible tmux call per operation when debugging. +#### Floating panes on tmux 3.7 (#690) + +On tmux 3.7, the operations create floating panes -- overlays with an absolute +size, position, and optional zoom. A ``new-pane`` operation, ``new_pane()`` on +the pane wrappers, and a curated MCP tool each open one, and a +{class}`~libtmux.experimental.workspace.ir.Workspace` can declare floating panes +on a window, including a pane that overlays a different window. + +#### Query and command live panes with `panes()` (#690) + +{func}`~libtmux.experimental.query.panes` is a lazy, chainable query over the +panes a running server has: ``filter``, ``order_by``, ``limit``, and ``map`` +compose and read nothing until a terminal call. The same query commands what it +selects -- ``commands()`` attaches per-pane actions (send keys, resize, select, +respawn, clear history, kill) that run as one folded tmux dispatch. A query +resolves against a live engine or a plain list of pane snapshots, so the same +code runs offline in tests. + +#### Drive tmux from an MCP server (#690) + +An optional Model Context Protocol server exposes tmux as tools an AI agent can +call, installed with the ``libtmux[mcp]`` extra and launched as +``libtmux-engine-mcp``. It offers a curated vocabulary of intuitive verbs +(``send_input``, ``wait_for_output``, ``split_pane``, ``capture_pane``, +``new_pane``, and the session, window, and pane lifecycle), a tool for every +individual operation, and plan tools that preview or build a whole workspace in +one call. + +The server is caller-aware: because a control-mode agent shares the server with +the panes it drives, it knows which pane launched it and refuses to kill or +respawn its own pane, window, or session. A safety level (``LIBTMUX_SAFETY``) +keeps mutating and destructive tools hidden until an operator opts in, and a +needle-free ``wait_for_output`` reports when a pane goes quiet after a command -- +no sentinel string -- and whether its process exited. + ## libtmux 0.62.0 (2026-07-12) libtmux 0.62.0 teaches libtmux objects to locate themselves and to resolve From 36d967ce8f0bf12719602192ed9dbd5c8a72d4e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:08:01 -0500 Subject: [PATCH 110/223] Mcp(refactor): Drop dead is_conservative_caller why: is_conservative_caller had no call sites -- the self-kill guards check the caller pane id inline and call socket_could_match directly, so the helper was dead duplication. what: - Remove is_conservative_caller from vocabulary/_caller.py - Point the module docstring at socket_could_match, the comparator the guards actually use --- .../experimental/mcp/vocabulary/_caller.py | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py index ae080749f..2fde3d234 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_caller.py +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -14,7 +14,7 @@ Everything here is pure (no tmux call, no fastmcp). A pane id is unique only within one tmux server, so identity is socket-scoped: :func:`is_strict_caller` (realpath-only, for the ``is_caller`` annotation) and the fail-safe -:func:`is_conservative_caller` (true-when-uncertain, for destructive guards). +:func:`socket_could_match` (true-when-uncertain, for destructive guards). """ from __future__ import annotations @@ -364,30 +364,3 @@ def is_strict_caller( if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: return False return socket_matches(socket, caller) - - -def is_conservative_caller( - pane_id: str | None, - socket: str | None, - caller: CallerContext, -) -> bool: - """Whether *pane_id* could be the caller's pane (conservative, for guards). - - Scoped to a matching pane id but biased to block under socket uncertainty -- - the comparator the self-kill guards use, so better discovery never makes the - destructive surface fail open. A different pane, or the same ``%N`` on a - provably different socket, is not the caller. - - Examples - -------- - >>> caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}) - >>> is_conservative_caller("%3", None, caller) - True - >>> is_conservative_caller("%9", None, caller) - False - >>> is_conservative_caller("%3", "/tmp/other", caller) - False - """ - if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: - return False - return socket_could_match(socket, caller) From 123edd460481ebe475fe2324e2f736adad9120a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:15:02 -0500 Subject: [PATCH 111/223] Engines(fix): Report tmux version over control mode why: run/arun resolve an engine's tmux version to drop flags an older tmux rejects, but only the subprocess engines implemented tmux_version. Over a control-mode engine, gating silently assumed latest and could emit flags the connected server rejects. what: - Add tmux_version() to ControlModeEngine and AsyncControlModeEngine, reading the connected server's tmux -V - Cover engine version-capability advertisement in test_execute --- .../engines/async_control_mode.py | 14 +++++++ .../experimental/engines/control_mode.py | 14 +++++++ tests/experimental/ops/test_execute.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index fbd0fd1a5..48899e8fb 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -32,6 +32,7 @@ from dataclasses import dataclass, field from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import render_control_line from libtmux.experimental.engines.control_mode import ( ControlModeError, @@ -150,6 +151,19 @@ def __init__( self._started = False self._dead: BaseException | None = None + def tmux_version(self) -> str | None: + """Report the connected server's tmux version (``tmux -V``). + + Implements + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so + version-gated operations render correctly over control mode; in-memory + engines omit it and resolution assumes latest. + """ + try: + return str(get_version(self.tmux_bin)) + except exc.LibTmuxException: + return None + async def start(self) -> None: """Spawn ``tmux -C``, consume the startup ACK, and start the reader.""" async with self._start_lock: diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 591812f06..1701ed0dd 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -28,6 +28,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult, render_control_line if t.TYPE_CHECKING: @@ -188,6 +189,19 @@ def __init__( self._proc: subprocess.Popen[bytes] | None = None self._selector: selectors.DefaultSelector | None = None + def tmux_version(self) -> str | None: + """Report the connected server's tmux version (``tmux -V``). + + Implements + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so + version-gated operations render correctly over control mode; in-memory + engines omit it and resolution assumes latest. + """ + try: + return str(get_version(self.tmux_bin)) + except exc.LibTmuxException: + return None + def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" return self.run_batch([request])[0] diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py index 8a0ef83fb..524920ba4 100644 --- a/tests/experimental/ops/test_execute.py +++ b/tests/experimental/ops/test_execute.py @@ -11,6 +11,11 @@ import pytest +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.engines.base import SupportsTmuxVersion +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.control_mode import ControlModeEngine +from libtmux.experimental.engines.subprocess import SubprocessEngine from libtmux.experimental.ops import SendKeys, SplitWindow, arun, run from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.exc import TmuxCommandError @@ -140,6 +145,38 @@ def test_resolve_engine_version_none_without_capability() -> None: assert resolve_engine_version(FakeEngine(), None) is None +class _CapabilityCase(t.NamedTuple): + """One engine and whether it reports a tmux version to the resolver.""" + + test_id: str + make_engine: t.Callable[[], object] + reports_version: bool + + +_CAPABILITY_CASES: tuple[_CapabilityCase, ...] = ( + _CapabilityCase("subprocess", SubprocessEngine, True), + _CapabilityCase("control_mode", ControlModeEngine, True), + _CapabilityCase("async_control_mode", AsyncControlModeEngine, True), + _CapabilityCase("concrete", ConcreteEngine, False), +) + + +@pytest.mark.parametrize( + "case", + _CAPABILITY_CASES, + ids=[c.test_id for c in _CAPABILITY_CASES], +) +def test_engine_advertises_version_capability(case: _CapabilityCase) -> None: + """Real-tmux engines satisfy SupportsTmuxVersion; simulators assume latest. + + A control-mode engine can query its server's version, so version-gated + rendering must fire over it; an in-memory engine cannot, so it omits the + capability and resolution assumes latest. + """ + engine = case.make_engine() + assert isinstance(engine, SupportsTmuxVersion) is case.reports_version + + def test_run_auto_resolves_engine_version() -> None: """run() asks the engine for its version when none is passed; gating fires.""" from libtmux.experimental.ops import CapturePane From 6ce6e99e04689ed19c679849e789572d6f72e9e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:17:50 -0500 Subject: [PATCH 112/223] Ops(docs): Fix stale fold comment in chain test why: the comment claimed fold defaults to False, but execute() has no fold kwarg -- folding is chosen by passing a planner, and the default SequentialPlanner dispatches one tmux call per op. what: - Replace the misleading comment in test_no_fold_dispatches_per_op --- tests/experimental/ops/test_chain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index d8babf1af..1ac1af278 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -117,7 +117,7 @@ def test_no_fold_dispatches_per_op() -> None: plan.add(RenameWindow(target=WindowId("@1"), name="x")) engine = _CountingEngine() - plan.execute(engine) # fold defaults to False + plan.execute(engine) # default planner: one dispatch per op assert len(engine.calls) == 2 From d1151e64bccc5106dc48bc0a6479f3d556ffd579 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:34:52 -0500 Subject: [PATCH 113/223] Models(feat): Add PaneSnapshot.floating flag why: the live pane query filter(floating=True) matched nothing because PaneSnapshot exposed no floating attribute, though the neo groundwork (#{pane_floating_flag}, tmux 3.7) and the floating-pane build surface already landed. what: - Add typed PaneSnapshot.floating derived from #{pane_floating_flag} (False when the token is absent on tmux < 3.7) - Cover filter(floating=True/False) in test_query --- src/libtmux/experimental/models/snapshots.py | 9 +++++++++ tests/experimental/test_query.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py index 4af4652d0..e13368bea 100644 --- a/src/libtmux/experimental/models/snapshots.py +++ b/src/libtmux/experimental/models/snapshots.py @@ -76,6 +76,13 @@ class PaneSnapshot: ('%1', 0, True, 80) >>> pane.current_command 'zsh' + + The ``floating`` flag reflects ``#{pane_floating_flag}`` (tmux 3.7+): + + >>> PaneSnapshot.from_format({"pane_id": "%9", "pane_floating_flag": "1"}).floating + True + >>> pane.floating + False """ pane_id: str = "" @@ -89,6 +96,7 @@ class PaneSnapshot: current_path: str | None = None title: str | None = None pid: int | None = None + floating: bool = False fields: Mapping[str, str] = field(default_factory=dict) @classmethod @@ -106,6 +114,7 @@ def from_format(cls, raw: Mapping[str, str]) -> PaneSnapshot: current_path=raw.get("pane_current_path"), title=raw.get("pane_title"), pid=_as_int(raw.get("pane_pid")), + floating=_as_bool(raw.get("pane_floating_flag")), fields=dict(raw), ) diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index e3a7c8ec5..01e366f24 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -45,6 +45,16 @@ def test_filter_lookup() -> None: assert [p.pane_id for p in result] == ["%1", "%3"] +def test_filter_floating() -> None: + """filter(floating=True) selects floating overlays (tmux 3.7+).""" + rows = ( + _pane("%1", 0, active=True, command="vim"), + PaneSnapshot.from_format({"pane_id": "%9", "pane_floating_flag": "1"}), + ) + assert [p.pane_id for p in panes().filter(floating=True).all(rows)] == ["%9"] + assert [p.pane_id for p in panes().filter(floating=False).all(rows)] == ["%1"] + + def test_order_by_and_limit() -> None: """order_by sorts and limit truncates.""" result = panes().order_by("pane_index").limit(2).all(ROWS) From 600d043930f7cd21b07fadb8778baf4ba2967308 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:50:14 -0500 Subject: [PATCH 114/223] Engines(fix): Reap control-mode phantom sessions why: a bare `tmux -C` connect implies new-session, so every control-mode connection spawned a throwaway session on the target server and left it behind -- against the default socket these accumulate in the user's session switcher, and reconnects pile up more. what: - Both control engines set destroy-unattached on their own (phantom) session right after connect, so tmux reaps it on disconnect - Add live phantom tests (sync + async): marked on connect, gone on close --- .../engines/async_control_mode.py | 41 +++++++++++ .../experimental/engines/control_mode.py | 19 +++++ .../test_async_control_mode_phantom.py | 73 +++++++++++++++++++ .../engines/test_control_mode_phantom.py | 48 ++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 tests/experimental/engines/test_async_control_mode_phantom.py create mode 100644 tests/experimental/engines/test_control_mode_phantom.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 48899e8fb..1589e3188 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -108,6 +108,20 @@ def _offer( return 0 +def _swallow_future(future: asyncio.Future[t.Any]) -> None: + """Retrieve a fire-and-forget future's outcome so it isn't flagged unretrieved. + + A reap command is dispatched without an awaiter; its future resolves in the + reader. Calling :meth:`asyncio.Future.exception` marks the result retrieved + so a tmux-side ``%error`` never surfaces as a noisy "exception was never + retrieved" warning. + """ + if future.cancelled(): + return + with contextlib.suppress(Exception): + future.exception() + + class AsyncControlModeEngine: """Execute tmux commands over one persistent async ``tmux -C`` connection. @@ -189,6 +203,7 @@ async def start(self) -> None: self._reader(), name="libtmux-async-control-reader", ) + await self._reap_own_session() self._started = True async def _consume_startup(self) -> None: @@ -221,6 +236,32 @@ async def _consume_startup(self) -> None: if self._parser.blocks(): # startup ACK seen and discarded return + async def _reap_own_session(self) -> None: + """Mark this control client's throwaway session ``destroy-unattached``. + + A bare ``tmux -C`` connect implies ``new-session``, so each connection + spawns a phantom session on the target server. Setting + ``destroy-unattached on`` on the *current* session (no ``-t``/``-g``, so + scoped to exactly that phantom, never global) makes tmux reap it the + moment this client disconnects, so control mode never litters the server + with throwaway sessions. Fire-and-forget: the reader resolves the result + future, which is swallowed. + """ + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + argv = ("set-option", "destroy-unattached", "on") + async with self._write_lock: + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + try: + proc.stdin.write((render_control_line(argv) + "\n").encode()) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + return + async def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" return (await self.run_batch([request]))[0] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 1701ed0dd..31e15611a 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -305,6 +305,7 @@ def _ensure_started(self) -> None: self._proc = proc self._selector = selector self._consume_startup() + self._reap_own_session() def _consume_startup(self) -> None: """Read and discard tmux's startup ACK block before any command. @@ -326,6 +327,24 @@ def _consume_startup(self) -> None: return self._parser.notifications() + def _reap_own_session(self) -> None: + """Mark this control client's throwaway session ``destroy-unattached``. + + A bare ``tmux -C`` connect implies ``new-session``, so set + ``destroy-unattached on`` on the *current* session (the phantom; no + ``-t``/``-g``, scoped to exactly it) right after connect. tmux reaps it + the moment the client disconnects, so control mode leaves no throwaway + sessions. Its result block is read and discarded here -- before any user + command -- so it cannot desync the next command. Best-effort. + """ + proc = self._proc + if proc is None or proc.stdin is None: + return + argv = ("set-option", "destroy-unattached", "on") + with contextlib.suppress(OSError, BrokenPipeError, ControlModeError): + self._write((render_control_line(argv) + "\n").encode()) + self._read_blocks(command_count(argv)) + def _drain_unsolicited(self) -> None: """Discard any blocks/notifications already buffered (non-blocking).""" selector = self._selector diff --git a/tests/experimental/engines/test_async_control_mode_phantom.py b/tests/experimental/engines/test_async_control_mode_phantom.py new file mode 100644 index 000000000..5f3062534 --- /dev/null +++ b/tests/experimental/engines/test_async_control_mode_phantom.py @@ -0,0 +1,73 @@ +"""Live: a control-mode connection reaps its own throwaway (phantom) session. + +A bare ``tmux -C`` implies ``new-session``, so each connect spawns a phantom +session on the *target* server. The engine sets ``destroy-unattached on`` on that +phantom so tmux reaps it the moment the client attaches elsewhere or disconnects +-- control-mode never litters the server with throwaway sessions (the user's +default socket included), and a reconnect storm cannot accumulate them. +""" + +from __future__ import annotations + +import asyncio +import time +import typing as t + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_phantom_session_marked_destroy_unattached(session: Session) -> None: + """The engine marks its own throwaway session ``destroy-unattached`` on connect.""" + + async def main() -> tuple[list[str], list[str]]: + engine = AsyncControlModeEngine.for_server(session.server) + try: + await engine.start() + own = ( + await engine.run( + CommandRequest.from_args("display-message", "-p", "#{session_id}"), + ) + ).stdout + opt = ( + await engine.run( + CommandRequest.from_args( + "show-options", "-t", own[0], "-v", "destroy-unattached" + ), + ) + ).stdout + return list(own), list(opt) + finally: + await engine.aclose() + + own, opt = asyncio.run(main()) + assert own and own[0].startswith("$") + assert opt == ["on"] + + +def test_phantom_session_reaped_on_close(session: Session) -> None: + """No phantom session lingers once the control connection closes.""" + server = session.server + before = len(server.sessions) + + async def main() -> int: + engine = AsyncControlModeEngine.for_server(server) + try: + await engine.start() + return len(server.sessions) # the phantom is present during the connection + finally: + await engine.aclose() + + during = asyncio.run(main()) + assert during == before + 1 # the connection spawned its phantom + + # tmux reaps the unattached destroy-unattached session when the client exits; + # poll briefly since the reap is asynchronous to the proc terminating. + for _ in range(60): + if len(server.sessions) == before: + break + time.sleep(0.05) + assert len(server.sessions) == before diff --git a/tests/experimental/engines/test_control_mode_phantom.py b/tests/experimental/engines/test_control_mode_phantom.py new file mode 100644 index 000000000..cef367259 --- /dev/null +++ b/tests/experimental/engines/test_control_mode_phantom.py @@ -0,0 +1,48 @@ +"""Live: the sync control-mode engine reaps its own phantom session too. + +The synchronous twin of ``test_async_control_mode_phantom``: a bare ``tmux -C`` +connect spawns a throwaway session, and the engine marks it ``destroy-unattached`` +so it is reaped on disconnect -- no litter on the target server. +""" + +from __future__ import annotations + +import time +import typing as t + +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.engines.control_mode import ControlModeEngine + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_sync_phantom_marked_destroy_unattached(session: Session) -> None: + """The sync engine marks its throwaway session ``destroy-unattached``.""" + with ControlModeEngine.for_server(session.server) as engine: + own = engine.run( + CommandRequest.from_args("display-message", "-p", "#{session_id}"), + ).stdout + opt = engine.run( + CommandRequest.from_args( + "show-options", "-t", own[0], "-v", "destroy-unattached" + ), + ).stdout + assert own and own[0].startswith("$") + assert list(opt) == ["on"] + + +def test_sync_phantom_reaped_on_close(session: Session) -> None: + """No phantom session lingers once the sync connection closes.""" + server = session.server + before = len(server.sessions) + with ControlModeEngine.for_server(server) as engine: + engine.run(CommandRequest.from_args("display-message", "-p", "#{session_id}")) + during = len(server.sessions) + assert during == before + 1 + + for _ in range(60): + if len(server.sessions) == before: + break + time.sleep(0.05) + assert len(server.sessions) == before From bb458c70a082a4125abe6e94b1f173da308acf6b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:54:33 -0500 Subject: [PATCH 115/223] Engines(fix): Close subscribers on engine death why: subscribe() looped on `await queue.get()` with nothing signalling death or close, so a consumer (the event push tool, the pull ring, the output monitor) hung forever once the async engine died or was closed. what: - Broadcast a stream-end sentinel to every subscriber queue from _mark_dead and aclose (force-put so it lands even on a full queue) - Gate subscribe() on _closing so a call after aclose ends at once - Add hermetic sentinel tests (death, full-queue eviction, post-close) --- .../engines/async_control_mode.py | 60 ++++++++++-- .../test_async_control_mode_sentinel.py | 92 +++++++++++++++++++ 2 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 tests/experimental/engines/test_async_control_mode_sentinel.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 1589e3188..277b41792 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -52,6 +52,7 @@ _DEFAULT_TIMEOUT = 30.0 _STARTUP_TIMEOUT = 5.0 _STOP_TIMEOUT = 2.0 +_STREAM_END = object() # broadcast to subscriber queues to end their async for @dataclass(frozen=True) @@ -108,6 +109,22 @@ def _offer( return 0 +def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: + """Put *item* on *queue*, evicting the oldest entry first when it is full. + + Like :func:`_offer` but drop-count-free: lands the stream-end sentinel even + on a queue already at ``maxsize``, so a slow consumer that hit backpressure + still gets closed instead of hanging on ``queue.get()``. + """ + try: + queue.put_nowait(item) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() # evict oldest; tolerable at death + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(item) + + def _swallow_future(future: asyncio.Future[t.Any]) -> None: """Retrieve a fire-and-forget future's outcome so it isn't flagged unretrieved. @@ -156,13 +173,14 @@ def __init__( self._parser = ControlModeParser() self._pending: collections.deque[_PendingCommand] = collections.deque() self._event_queue_size = event_queue_size - self._subscribers: set[asyncio.Queue[ControlNotification]] = set() + self._subscribers: set[asyncio.Queue[t.Any]] = set() self._dropped_notifications = 0 self._proc: asyncio.subprocess.Process | None = None self._reader_task: asyncio.Task[None] | None = None self._start_lock = asyncio.Lock() self._write_lock = asyncio.Lock() self._started = False + self._closing = False self._dead: BaseException | None = None def tmux_version(self) -> str | None: @@ -326,16 +344,25 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: Each subscriber gets its own queue, so concurrent subscribers (the event push tool, the pull ring, the output monitor) each see *every* notification rather than competing for one shared stream. The iterator - runs until the engine is closed or the caller stops iterating; its queue - is unregistered on exit. + runs until the engine dies or is closed, or the caller stops iterating; + its queue is unregistered on exit. When the engine dies or closes, + ``_STREAM_END`` is broadcast to every subscriber queue so the ``async + for`` ends cleanly instead of hanging on ``queue.get()``. A subscribe() + after :meth:`aclose` yields nothing (the ``_closing`` gate), since no + broadcast would ever reach its fresh queue. """ - queue: asyncio.Queue[ControlNotification] = asyncio.Queue( + if self._closing: + return + queue: asyncio.Queue[t.Any] = asyncio.Queue( maxsize=self._event_queue_size, ) self._subscribers.add(queue) try: while True: - yield await queue.get() + item = await queue.get() + if item is _STREAM_END: + return + yield item finally: self._subscribers.discard(queue) @@ -345,9 +372,14 @@ def dropped_notifications(self) -> int: return self._dropped_notifications async def aclose(self) -> None: - """Tear down the connection: cancel the reader, fail pending, kill proc.""" + """Tear down: flag closing, cancel the reader, end subscribers, kill proc. + + Setting ``_closing`` first makes a subscribe() racing the close end at + once instead of registering a queue no broadcast will reach. + """ if not self._started: return + self._closing = True self._started = False reader = self._reader_task self._reader_task = None @@ -355,6 +387,7 @@ async def aclose(self) -> None: reader.cancel() with contextlib.suppress(asyncio.CancelledError): await reader + self._broadcast_stream_end() self._fail_pending(ControlModeError("control-mode engine closed")) proc = self._proc self._proc = None @@ -433,11 +466,24 @@ def _publish(self, line: bytes) -> None: for queue in self._subscribers: self._dropped_notifications += _offer(queue, notification) + def _broadcast_stream_end(self) -> None: + """Push the stream-end sentinel to every subscriber, then clear them. + + Uses :func:`_force_put` so the sentinel lands even on a queue already at + ``maxsize`` (a slow consumer that hit backpressure); otherwise the + sentinel would be lost and the consumer would hang forever on + ``queue.get()`` -- the exact bug this guards against. + """ + for queue in list(self._subscribers): + _force_put(queue, _STREAM_END) + self._subscribers.clear() + def _mark_dead(self, error: BaseException) -> None: - """Record the engine as dead and fail all pending commands.""" + """Record the engine as dead, fail pending commands, end subscribers.""" if self._dead is None: self._dead = error self._fail_pending(error) + self._broadcast_stream_end() def _fail_pending(self, error: BaseException) -> None: """Fail every queued command future with *error*.""" diff --git a/tests/experimental/engines/test_async_control_mode_sentinel.py b/tests/experimental/engines/test_async_control_mode_sentinel.py new file mode 100644 index 000000000..f3bdf2029 --- /dev/null +++ b/tests/experimental/engines/test_async_control_mode_sentinel.py @@ -0,0 +1,92 @@ +"""A dead engine must CLOSE subscriber generators, not hang them.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines.async_control_mode import ( + AsyncControlModeEngine, + ControlNotification, +) +from libtmux.experimental.engines.control_mode import ControlModeError + + +def test_subscribe_ends_when_engine_marked_dead() -> None: + """A subscriber generator must finish (not hang) when the engine is marked dead.""" + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine() + # do not spawn tmux: drive subscribe() + _mark_dead directly + started = asyncio.Event() + seen: list[ControlNotification] = [] + + async def consume() -> None: + started.set() # signalled before the async for registers its queue + # collect via async comprehension (avoids PERF401 lint) + seen.extend([note async for note in engine.subscribe()]) + + task = asyncio.create_task(consume()) + await started.wait() # consumer registered its queue and is on queue.get() + engine._mark_dead(ControlModeError("boom")) + await asyncio.wait_for(task, timeout=1.0) # must NOT hang + return seen + + asyncio.run(main()) + + +def test_subscribe_ends_when_dead_with_full_queue() -> None: + """The death sentinel must land even when a subscriber queue is at maxsize. + + A slow consumer can let its bounded queue fill to ``maxsize`` (the + drop-oldest ``_offer`` path). The death broadcast must still close such a + consumer, so it evicts the oldest entry to make room for the sentinel + instead of silently dropping it. + """ + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine(event_queue_size=2) + started = asyncio.Event() + seen: list[ControlNotification] = [] + + async def consume() -> None: + started.set() + seen.extend([note async for note in engine.subscribe()]) + + task = asyncio.create_task(consume()) + await started.wait() # queue registered, consumer blocked on queue.get() + + queue: asyncio.Queue[t.Any] = next(iter(engine._subscribers)) + first = ControlNotification.parse(b"%output %1 first") + second = ControlNotification.parse(b"%output %2 second") + queue.put_nowait(first) + queue.put_nowait(second) # queue now at maxsize=2 (full) + + engine._mark_dead(ControlModeError("boom")) + await asyncio.wait_for(task, timeout=1.0) # must NOT hang despite full queue + # the oldest item was evicted so the sentinel could land + assert first not in seen + return seen + + asyncio.run(main()) + + +def test_subscribe_ends_immediately_after_close() -> None: + """A subscribe() after aclose() must end at once, not hang on queue.get(). + + aclose() broadcasts the stream-end sentinel and clears the subscriber set, + so a queue registered afterwards would never receive a sentinel. The + ``_closing`` gate makes subscribe() yield nothing and return immediately. + """ + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine() + engine._started = True # pretend a connection was established + await engine.aclose() # flips _closing, broadcasts sentinel, clears subs + + async def drain() -> list[ControlNotification]: + return [note async for note in engine.subscribe()] + + return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang + + assert asyncio.run(main()) == [] From 0f3853786b0671cd7257903d1996299fde8c78b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:41:54 -0500 Subject: [PATCH 116/223] Query(feat): Split-type forward-ref pane handles why: a fluent build needs to target a pane an earlier op will create. Splitting the concrete PaneRef into two handle types makes reading an id off a not-yet-created pane a static type error under mypy and ty, not a runtime surprise. what: - Add _PaneRefBase (shared verbs: cmd, split, do), ForwardPaneRef (no snapshot reads), and PaneRef (concrete: pane_id/active/pane) - split() records the create and returns a forward handle; do() threads a recorder into a fluent chain - Widen BoundPaneCommands.target to Target so a forward SlotRef flows - Cover the split/do verbs and the concrete-vs-forward read surface --- src/libtmux/experimental/query.py | 100 +++++++++++++++++++++++++----- tests/experimental/test_query.py | 59 +++++++++++++++++- 2 files changed, 144 insertions(+), 15 deletions(-) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index 05d752fa9..0ff2ec57d 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -43,15 +43,18 @@ RespawnPane, SelectPane, SendKeys, + SplitWindow, run, ) if t.TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence + from typing_extensions import Self + from libtmux.experimental.models.snapshots import PaneSnapshot from libtmux.experimental.ops import Planner, PlanResult - from libtmux.experimental.ops._types import SlotRef + from libtmux.experimental.ops._types import SlotRef, Target #: A source of pane snapshots: an engine to read from, or pre-taken snapshots. PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] @@ -149,11 +152,13 @@ class BoundPaneCommands: Each method appends a typed operation targeting the bound pane to the plan and returns its :class:`~..ops._types.SlotRef`, so commands compose and the - plan folds to a single tmux dispatch. + plan folds to a single tmux dispatch. ``target`` is a :data:`~..ops._types.Target` + so a forward :class:`~..ops._types.SlotRef` (a pane an earlier op creates) + flows through as well as a concrete :class:`~..ops._types.PaneId`. """ plan: LazyPlan - target: PaneId + target: Target def send_keys( self, @@ -198,26 +203,93 @@ def kill(self) -> SlotRef: @dataclass(frozen=True) -class PaneRef: - """A matched pane plus a ``cmd`` namespace that records into a plan.""" +class _PaneRefBase: + """The verbs shared by concrete and forward pane handles. + + An immutable pointer into a *mutable* :class:`LazyPlan`. Structural verbs + (:meth:`split`) record a create op and return a *forward* handle to the + not-yet-created pane; leaf commands live under :attr:`cmd`; :meth:`do` + threads a side-effecting recorder into a fluent chain. + """ - pane: PaneSnapshot plan: LazyPlan + target: Target + + @property + def cmd(self) -> BoundPaneCommands: + """Pane commands bound to this handle's target (recorded into the plan).""" + return BoundPaneCommands(self.plan, self.target) + + def split(self, *, horizontal: bool = False) -> ForwardPaneRef: + """Split this pane; return a forward handle to the new pane. + + Examples + -------- + >>> plan = LazyPlan() + >>> new = _PaneRefBase(plan, PaneId("%1")).split() + >>> isinstance(new, ForwardPaneRef) + True + >>> [op.kind for op in plan.operations] + ['split_window'] + """ + slot = self.plan.add(SplitWindow(target=self.target, horizontal=horizontal)) + return ForwardPaneRef(self.plan, slot) + + def do(self, fn: Callable[[BoundPaneCommands], object]) -> Self: + """Apply *fn* to this handle's :attr:`cmd`, returning the handle. + + Examples + -------- + >>> plan = LazyPlan() + >>> h = _PaneRefBase(plan, PaneId("%1")) + >>> h.do(lambda c: c.send_keys("vim")) is h + True + >>> [op.kind for op in plan.operations] + ['send_keys'] + """ + fn(self.cmd) + return self + + +@dataclass(frozen=True) +class ForwardPaneRef(_PaneRefBase): + """A pane an earlier operation will create. + + Carries a forward :class:`~..ops._types.SlotRef`; it has no snapshot, so + reading a pane id/attribute off it is a *static* type error (the id is + unknown until the plan runs). Keep building instead -- ``split().do(...)``. + """ + + +@dataclass(frozen=True) +class PaneRef(_PaneRefBase): + """A concrete matched pane: the shared verbs plus snapshot reads. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import PaneSnapshot + >>> snap = PaneSnapshot.from_format({"pane_id": "%1", "pane_active": "1"}) + >>> ref = PaneRef(LazyPlan(), PaneId("%1"), snapshot=snap) + >>> ref.pane_id, ref.active + ('%1', True) + """ + + snapshot: PaneSnapshot + + @property + def pane(self) -> PaneSnapshot: + """The underlying pane snapshot.""" + return self.snapshot @property def pane_id(self) -> str: """The pane's id.""" - return self.pane.pane_id + return self.snapshot.pane_id @property def active(self) -> bool: """Whether the pane is active in its window.""" - return self.pane.active - - @property - def cmd(self) -> BoundPaneCommands: - """Pane commands bound to this pane (recorded into the plan).""" - return BoundPaneCommands(self.plan, PaneId(self.pane.pane_id)) + return self.snapshot.active @dataclass(frozen=True) @@ -250,7 +322,7 @@ def to_plan(self, source: PaneSource) -> LazyPlan: """ plan = LazyPlan() for pane in self.query.all(source): - self.mapper(PaneRef(pane, plan)) + self.mapper(PaneRef(plan, PaneId(pane.pane_id), snapshot=pane)) return plan def run( diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index 01e366f24..a3d9c3a41 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -4,8 +4,11 @@ import typing as t +import pytest + from libtmux.experimental.models.snapshots import PaneSnapshot -from libtmux.experimental.query import PaneQuery, panes +from libtmux.experimental.ops import LazyPlan, PaneId +from libtmux.experimental.query import ForwardPaneRef, PaneQuery, PaneRef, panes if t.TYPE_CHECKING: from libtmux.session import Session @@ -29,6 +32,60 @@ def _pane(pane_id: str, index: int, *, active: bool, command: str) -> PaneSnapsh ) +def _concrete(plan: LazyPlan) -> PaneRef: + return PaneRef( + plan, + PaneId("%1"), + snapshot=_pane("%1", 0, active=True, command="vim"), + ) + + +def test_split_returns_forward_handle() -> None: + """A structural verb records a create and returns a forward handle.""" + plan = LazyPlan() + new = _concrete(plan).split() + assert isinstance(new, ForwardPaneRef) + assert [op.kind for op in plan.operations] == ["split_window"] + + +def test_do_chains_on_the_handle() -> None: + """do() records into the plan and returns the same handle.""" + plan = LazyPlan() + ref = _concrete(plan) + assert ref.do(lambda c: c.send_keys("vim")) is ref + assert [op.kind for op in plan.operations] == ["send_keys"] + + +class _ReadCase(t.NamedTuple): + """Whether a handle exposes concrete pane reads (concrete) or not (forward).""" + + test_id: str + forward: bool + + +_READ_CASES: tuple[_ReadCase, ...] = ( + _ReadCase("concrete_reads", forward=False), + _ReadCase("forward_no_reads", forward=True), +) + + +@pytest.mark.parametrize("case", _READ_CASES, ids=[c.test_id for c in _READ_CASES]) +def test_handle_read_surface(case: _ReadCase) -> None: + """Concrete handles expose pane reads; forward handles have none. + + The forward handle's absence of ``pane_id`` is what makes a premature read a + *static* type error (mypy + ty), with the structural absence as its runtime + shadow. + """ + plan = LazyPlan() + concrete = _concrete(plan) + handle: object = concrete.split() if case.forward else concrete + assert hasattr(handle, "pane_id") is (not case.forward) + if not case.forward: + assert concrete.pane_id == "%1" + assert concrete.active is True + + def test_panes_returns_query() -> None: """panes() starts an empty, immutable query.""" assert panes() == PaneQuery() From a70fddfcb4f6d81ce9951d3d932caceb378010e5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:44:43 -0500 Subject: [PATCH 117/223] Fluent(feat): Add plan() forward-ref build tier why: the split-type pane handles give forward-ref panes, but a user still needs a fluent entry to declare a whole session tree. plan() records a session/window/pane build into one LazyPlan that folds to a few tmux dispatches instead of one call per operation. what: - Add experimental/fluent.py: plan() -> PlanBuilder, SessionRef, WindowRef - Name-address sessions/windows (folds stay intact); reach a pane via the creator's captured first-pane SlotRef (a forward handle) - run()/arun() default to MarkedPlanner; preview() is a pure argv dry-run - Cover the build shape, an offline fold over ConcreteEngine, and a live build --- src/libtmux/experimental/fluent.py | 196 +++++++++++++++++++++++++++++ tests/experimental/test_fluent.py | 89 +++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/libtmux/experimental/fluent.py create mode 100644 tests/experimental/test_fluent.py diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py new file mode 100644 index 000000000..5641287aa --- /dev/null +++ b/src/libtmux/experimental/fluent.py @@ -0,0 +1,196 @@ +"""A fluent, forward-ref builder that folds a session build to a few dispatches. + +``plan()`` opens a :class:`PlanBuilder` -- a thin recorder over a Core +:class:`~libtmux.experimental.ops.plan.LazyPlan`. Navigating it +(:meth:`PlanBuilder.new_session` -> :class:`SessionRef` -> +:class:`WindowRef` -> :meth:`WindowRef.pane`) records create operations and +hands back forward handles (:class:`~libtmux.experimental.query.ForwardPaneRef`) +that address objects the plan will create. Nothing runs until +:meth:`PlanBuilder.run` (or its async twin :meth:`PlanBuilder.arun`), which folds +the recorded operations into a handful of ``tmux a ; b`` dispatches by default +(a :class:`~libtmux.experimental.ops.planner.MarkedPlanner`). + +Named objects (sessions, windows) are addressed by name so their sub-operations +fold; a pane -- which has no name -- is addressed by a forward +:class:`~libtmux.experimental.ops._types.SlotRef`, resolved from the creating +operation's captured id at execution. + +Examples +-------- +>>> from libtmux.experimental.engines.concrete import ConcreteEngine +>>> p = plan() +>>> pane = p.new_session("dev").window().pane() +>>> bottom = pane.do(lambda c: c.send_keys("vim")).split() +>>> bottom.do(lambda c: c.send_keys("htop")) is bottom +True +>>> [op.kind for op in p.plan.operations] +['new_session', 'send_keys', 'split_window', 'send_keys'] +>>> p.run(ConcreteEngine()).ok +True +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops import ( + LazyPlan, + MarkedPlanner, + NameRef, + NewSession, + NewWindow, +) +from libtmux.experimental.query import ForwardPaneRef + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops import Planner, PlanResult + from libtmux.experimental.ops._types import SlotRef + + +@dataclass(frozen=True) +class WindowRef: + """A window in a plan; navigate to its first pane. + + ``first_pane`` is a forward :class:`~..ops._types.SlotRef` to the window's + first pane (captured by the creating ``new-session`` / ``new-window``). + """ + + plan: LazyPlan + first_pane: SlotRef + + def pane(self) -> ForwardPaneRef: + """Return a forward handle to the window's first pane. + + Examples + -------- + >>> p = plan() + >>> ref = p.new_session("dev").window().pane() + >>> isinstance(ref, ForwardPaneRef) + True + """ + return ForwardPaneRef(self.plan, self.first_pane) + + +@dataclass(frozen=True) +class SessionRef: + """A session in a plan; reach its first window or add another. + + The session is name-addressed (so its window operations fold); ``create`` is + the ``new-session`` slot, whose captured first pane backs the first window. + """ + + plan: LazyPlan + name: str + create: SlotRef + + def window(self) -> WindowRef: + """Return the session's first window. + + Examples + -------- + >>> isinstance(plan().new_session("dev").window(), WindowRef) + True + """ + return WindowRef(self.plan, self.create.pane) + + def new_window(self, name: str) -> WindowRef: + """Create another window in this session (name-addressed). + + Examples + -------- + >>> p = plan() + >>> _ = p.new_session("dev").new_window("logs") + >>> [op.kind for op in p.plan.operations] + ['new_session', 'new_window'] + """ + slot = self.plan.add( + NewWindow(target=NameRef(self.name), name=name, capture_pane=True), + ) + return WindowRef(self.plan, slot.pane) + + +@dataclass(frozen=True) +class PlanBuilder: + """A fluent recorder over a :class:`LazyPlan`; :meth:`run` folds by default.""" + + plan: LazyPlan = field(default_factory=LazyPlan) + + def new_session(self, name: str) -> SessionRef: + """Create a session, capturing its first pane for forward refs. + + Examples + -------- + >>> p = plan() + >>> ref = p.new_session("dev") + >>> isinstance(ref, SessionRef) + True + >>> [op.kind for op in p.plan.operations] + ['new_session'] + """ + slot = self.plan.add(NewSession(session_name=name, capture_panes=True)) + return SessionRef(self.plan, name, slot) + + def run( + self, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Build over *engine*, folding to a few dispatches (``MarkedPlanner``). + + Examples + -------- + >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> p = plan() + >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) + >>> p.run(ConcreteEngine()).ok + True + """ + return self.plan.execute( + engine, + version=version, + planner=planner or MarkedPlanner(), + ) + + async def arun( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Async twin of :meth:`run` (same fold, ``await``ed).""" + return await self.plan.aexecute( + engine, + version=version, + planner=planner or MarkedPlanner(), + ) + + def preview(self, *, version: str | None = None) -> list[tuple[str, ...] | None]: + """Render a pure argv dry-run of the recorded plan (no engine). + + Examples + -------- + >>> p = plan() + >>> _ = p.new_session("dev") + >>> argv = p.preview()[0] + >>> argv[:4] + ('new-session', '-d', '-s', 'dev') + >>> argv[-1] + '#{session_id} #{window_id} #{pane_id}' + """ + return self.plan.preview(version=version) + + +def plan() -> PlanBuilder: + """Start a fluent, forward-ref plan build. + + Examples + -------- + >>> plan().plan.operations + () + """ + return PlanBuilder() diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py new file mode 100644 index 000000000..8e596b7a0 --- /dev/null +++ b/tests/experimental/test_fluent.py @@ -0,0 +1,89 @@ +"""Tests for the fluent forward-ref plan builder (``plan()``).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.fluent import PlanBuilder, plan +from libtmux.experimental.query import ForwardPaneRef + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _one_window_two_panes(p: PlanBuilder) -> None: + pane = p.new_session("dev").window().pane() + pane.do(lambda c: c.send_keys("vim")).split().do( + lambda c: c.send_keys("pytest -q"), + ) + + +def _two_windows(p: PlanBuilder) -> None: + sess = p.new_session("dev") + sess.window().pane().do(lambda c: c.send_keys("vim")) + sess.new_window("logs").pane().do(lambda c: c.send_keys("tail -f log")) + + +class _BuildCase(t.NamedTuple): + """A fluent build and the operation sequence it should record.""" + + test_id: str + build: t.Callable[[PlanBuilder], None] + kinds: list[str] + + +_BUILD_CASES: tuple[_BuildCase, ...] = ( + _BuildCase( + "one_window_two_panes", + _one_window_two_panes, + ["new_session", "send_keys", "split_window", "send_keys"], + ), + _BuildCase( + "two_windows", + _two_windows, + ["new_session", "send_keys", "new_window", "send_keys"], + ), +) + + +@pytest.mark.parametrize("case", _BUILD_CASES, ids=[c.test_id for c in _BUILD_CASES]) +def test_builder_records_ops(case: _BuildCase) -> None: + """The fluent build records the expected operation sequence.""" + p = plan() + case.build(p) + assert [op.kind for op in p.plan.operations] == case.kinds + + +@pytest.mark.parametrize("case", _BUILD_CASES, ids=[c.test_id for c in _BUILD_CASES]) +def test_builder_runs_offline(case: _BuildCase) -> None: + """The build resolves forward refs and folds over the in-memory engine.""" + p = plan() + case.build(p) + assert p.run(ConcreteEngine()).ok + + +def test_window_pane_is_forward_handle() -> None: + """A window's first pane is a forward handle with no snapshot reads.""" + ref = plan().new_session("dev").window().pane() + assert isinstance(ref, ForwardPaneRef) + assert not hasattr(ref, "pane_id") + + +def test_build_session_live(session: Session) -> None: + """A fluent build creates a real session with the declared panes.""" + from libtmux.experimental.engines.subprocess import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + p = plan() + pane = p.new_session("fluentdev").window().pane() + pane.do(lambda c: c.send_keys("echo top", enter=False)).split().do( + lambda c: c.send_keys("echo bottom", enter=False), + ) + p.run(engine).raise_for_status() + + built = [s for s in session.server.sessions if s.session_name == "fluentdev"] + assert len(built) == 1 + assert len(built[0].windows[0].panes) == 2 From 5c78f2d0aa30c3c367b08c30989e29ea13675b48 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:53:38 -0500 Subject: [PATCH 118/223] Workspace(feat): Add freeze (live server to IR) why: analyze() and to_dict() cover the config->IR->dict round-trip, but there was no way back from a LIVE server to declarative IR. freeze() closes the loop -- reverse-analyze a running session into a Workspace you can rebuild elsewhere (tmuxp `freeze`). what: - Add experimental/workspace/freeze.py: freeze(ServerSnapshot) -> Workspace (pure), plus freeze_server/afreeze_server rebuilding the whole tree from one list-panes -a read - Export freeze/freeze_server/afreeze_server from the workspace package --- .../experimental/workspace/__init__.py | 8 + src/libtmux/experimental/workspace/freeze.py | 249 ++++++++++++++++++ tests/experimental/test_freeze.py | 213 +++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 src/libtmux/experimental/workspace/freeze.py create mode 100644 tests/experimental/test_freeze.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 99b572142..7eb3e9049 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -38,6 +38,11 @@ WindowCreated, WorkspaceBuilt, ) +from libtmux.experimental.workspace.freeze import ( + afreeze_server, + freeze, + freeze_server, +) from libtmux.experimental.workspace.ir import ( Command, Float, @@ -65,9 +70,12 @@ "WorkspaceBuilt", "WorkspaceCompileError", "abuild_workspace", + "afreeze_server", "analyze", "build_workspace", "compile_full", "compile_workspace", "confirm", + "freeze", + "freeze_server", ) diff --git a/src/libtmux/experimental/workspace/freeze.py b/src/libtmux/experimental/workspace/freeze.py new file mode 100644 index 000000000..84c424fb0 --- /dev/null +++ b/src/libtmux/experimental/workspace/freeze.py @@ -0,0 +1,249 @@ +"""Reverse-analyze a live server snapshot into the declarative IR -- the round-trip. + +:func:`~libtmux.experimental.workspace.analyzer.analyze` lowers a tmuxp-style +config *into* a :class:`~libtmux.experimental.workspace.ir.Workspace`; +:func:`freeze` is its inverse over **live** state. It walks an immutable +:class:`~libtmux.experimental.models.snapshots.ServerSnapshot` back into a +``Workspace`` that :meth:`~..ir.Workspace.build` / :meth:`~..ir.Workspace.compile` +can replay, so a running session can be captured as reusable, version-controllable +IR (tmuxp's ``freeze``). It is **lossy by design**: scrollback, live process +state, and a pane sitting at a bare shell are not reconstructed. + +The north star -- *fewest backend calls* -- holds: :func:`freeze_server` / +:func:`afreeze_server` rebuild the **entire** session/window/pane tree from a +**single** ``list-panes -a -F`` read; the mapping itself is pure. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.models.snapshots import ServerSnapshot +from libtmux.experimental.workspace.ir import Pane, Window, Workspace + +if t.TYPE_CHECKING: + from collections.abc import Collection, Iterable + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.models.snapshots import ( + PaneSnapshot, + SessionSnapshot, + WindowSnapshot, + ) + +#: Bare shells whose presence as a pane's *current command* means "no command": +#: freezing such a pane yields an empty pane, not a nested shell (tmuxp parity). +#: Override via the ``shells`` argument to keep or widen the set. +SHELLS: frozenset[str] = frozenset( + { + "sh", + "bash", + "zsh", + "fish", + "dash", + "ksh", + "tcsh", + "csh", + "ash", + "nu", + "xonsh", + "elvish", + "pwsh", + }, +) + +#: The tmux fields one ``list-panes -a -F`` read needs to rebuild the whole tree. +FREEZE_FIELDS: tuple[str, ...] = ( + "session_id", + "session_name", + "window_id", + "window_index", + "window_name", + "window_layout", + "window_active", + "pane_id", + "pane_index", + "pane_active", + "pane_current_command", + "pane_current_path", +) +_SEP = "\t" +#: The ``-F`` format string covering :data:`FREEZE_FIELDS` (one read, whole tree). +FREEZE_FORMAT: str = _SEP.join(f"#{{{field}}}" for field in FREEZE_FIELDS) + + +def _pick_session( + server: ServerSnapshot, + selector: str | None, +) -> SessionSnapshot: + """Choose the one session to freeze (by name/id, or the sole one).""" + sessions = server.sessions + if not sessions: + msg = "cannot freeze an empty server (no sessions)" + raise ValueError(msg) + if selector is None: + if len(sessions) == 1: + return sessions[0] + names = ", ".join(s.name or s.session_id for s in sessions) + msg = ( + f"ambiguous freeze: {len(sessions)} sessions ({names}); " + f"pass session= to choose one" + ) + raise ValueError(msg) + for session in sessions: + if selector in (session.name, session.session_id): + return session + names = ", ".join(s.name or s.session_id for s in sessions) + msg = f"no session matching {selector!r} (have: {names})" + raise ValueError(msg) + + +def _freeze_pane(pane: PaneSnapshot, shells: Collection[str]) -> Pane: + """Map one pane snapshot to a declarative :class:`~..ir.Pane`. + + A pane sitting at a bare shell (its ``current_command`` is in *shells*) + freezes to an empty pane -- replaying it as a command would nest a shell. + """ + command = pane.current_command + run = None if command is None or command in shells else command + return Pane(run=run, focus=pane.active, start_directory=pane.current_path) + + +def _freeze_window(window: WindowSnapshot, shells: Collection[str]) -> Window: + """Map one window snapshot and its panes to a declarative :class:`~..ir.Window`.""" + return Window( + name=window.name, + layout=window.layout, + focus=window.active, + panes=[_freeze_pane(pane, shells) for pane in window.panes], + ) + + +def freeze( + snapshot: ServerSnapshot, + *, + session: str | None = None, + shells: Collection[str] = SHELLS, +) -> Workspace: + """Reverse-analyze a live :class:`ServerSnapshot` into a declarative Workspace. + + The inverse of :func:`~..analyzer.analyze`: capture what is *running* as + reusable IR. Pure -- no tmux. Lossy by design (no scrollback / process state; + a bare-shell pane becomes an empty pane). + + Parameters + ---------- + snapshot : ServerSnapshot + The live server tree (e.g. from :meth:`ServerSnapshot.from_pane_rows`). + session : str or None + Which session to freeze, by ``session_name`` or ``session_id``. ``None`` + freezes the sole session and raises when the server holds several. + shells : Collection[str] + Commands treated as "a bare shell" -> an empty pane (default + :data:`SHELLS`). + + Returns + ------- + Workspace + A declarative spec that ``build``/``compile`` replays. + + Raises + ------ + ValueError + When the server is empty, *session* is ambiguous, or the named session + is absent. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import ServerSnapshot + >>> server = ServerSnapshot.from_pane_rows([ + ... {"session_id": "$0", "session_name": "dev", "window_id": "@1", + ... "window_index": "0", "window_name": "editor", "pane_id": "%1", + ... "pane_index": "0", "pane_active": "1", "pane_current_command": "vim"}, + ... {"session_id": "$0", "session_name": "dev", "window_id": "@1", + ... "window_index": "0", "window_name": "editor", "pane_id": "%2", + ... "pane_index": "1", "pane_current_command": "zsh"}, + ... ]) + >>> ws = freeze(server) + >>> ws.name + 'dev' + >>> [c.cmd for c in ws.windows[0].panes[0].commands] + ['vim'] + >>> ws.windows[0].panes[1].run is None # a bare shell -> empty pane + True + """ + chosen = _pick_session(snapshot, session) + return Workspace( + name=chosen.name or chosen.session_id, + windows=[_freeze_window(window, shells) for window in chosen.windows], + ) + + +def _rows(stdout: Iterable[str]) -> list[dict[str, str]]: + """Parse ``list-panes -F`` tab-separated lines into per-pane field dicts.""" + rows: list[dict[str, str]] = [] + for line in stdout: + if not line: + continue + parts = line.split(_SEP) + # zip(strict=False) tolerates a short row (a trailing empty field tmux drops) + rows.append(dict(zip(FREEZE_FIELDS, parts, strict=False))) + return rows + + +def freeze_server( + engine: TmuxEngine, + *, + session: str | None = None, + shells: Collection[str] = SHELLS, +) -> Workspace: + r"""Freeze a live server into IR with a **single** ``list-panes`` read. + + Reads the whole session/window/pane tree in one ``list-panes -a -F`` dispatch, + builds a :class:`ServerSnapshot`, and reverse-analyzes it via :func:`freeze`. + + Examples + -------- + >>> from libtmux.experimental.engines.base import CommandResult + >>> class _Engine: # one read returns the whole tree + ... def run(self, request): + ... row = "$0\tdev\t@1\t0\teditor\t\t1\t%1\t0\t1\tvim\t/work" + ... return CommandResult(cmd=("tmux",), stdout=(row,)) + >>> freeze_server(_Engine()).name + 'dev' + """ + from libtmux.experimental.engines.base import CommandRequest + + result = engine.run( + CommandRequest.from_args("list-panes", "-a", "-F", FREEZE_FORMAT), + ) + server = ServerSnapshot.from_pane_rows(_rows(result.stdout)) + return freeze(server, session=session, shells=shells) + + +async def afreeze_server( + engine: AsyncTmuxEngine, + *, + session: str | None = None, + shells: Collection[str] = SHELLS, +) -> Workspace: + r"""Async twin of :func:`freeze_server` (one awaited ``list-panes`` read). + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.base import CommandResult + >>> class _AEngine: + ... async def run(self, request): + ... row = "$0\tdev\t@1\t0\tmain\t\t1\t%1\t0\t1\tvim\t/w" + ... return CommandResult(cmd=("tmux",), stdout=(row,)) + >>> asyncio.run(afreeze_server(_AEngine())).name + 'dev' + """ + from libtmux.experimental.engines.base import CommandRequest + + result = await engine.run( + CommandRequest.from_args("list-panes", "-a", "-F", FREEZE_FORMAT), + ) + server = ServerSnapshot.from_pane_rows(_rows(result.stdout)) + return freeze(server, session=session, shells=shells) diff --git a/tests/experimental/test_freeze.py b/tests/experimental/test_freeze.py new file mode 100644 index 000000000..54651c71a --- /dev/null +++ b/tests/experimental/test_freeze.py @@ -0,0 +1,213 @@ +"""Tests for ``freeze`` -- a live server snapshot reverse-analyzed into IR. + +The pure core (:func:`freeze`) maps an immutable +:class:`~libtmux.experimental.models.snapshots.ServerSnapshot` into a declarative +:class:`~libtmux.experimental.workspace.ir.Workspace`, closing the round-trip +``analyze`` opens. These units feed synthetic snapshots -- no tmux. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.models.snapshots import ServerSnapshot +from libtmux.experimental.workspace.freeze import SHELLS, afreeze_server, freeze + +if t.TYPE_CHECKING: + from libtmux.experimental.workspace.ir import Workspace + from libtmux.session import Session + + +def _server(*rows: dict[str, str]) -> ServerSnapshot: + """Build a ServerSnapshot from flat per-pane rows (one list-panes read).""" + return ServerSnapshot.from_pane_rows(rows) + + +def test_freeze_maps_session_window_pane() -> None: + """A single session's tree becomes a Workspace of Windows of Panes.""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "editor", + "window_layout": "main-vertical", + "window_active": "1", + "pane_id": "%1", + "pane_index": "0", + "pane_active": "1", + "pane_current_command": "vim", + "pane_current_path": "/home/d/work", + }, + ) + ws = freeze(server) + assert ws.name == "dev" + assert [w.name for w in ws.windows] == ["editor"] + win = ws.windows[0] + assert win.layout == "main-vertical" + assert win.focus is True # the active window + pane = win.panes[0] + assert [c.cmd for c in pane.commands] == ["vim"] + assert pane.start_directory == "/home/d/work" + assert pane.focus is True # the active pane + + +def test_freeze_drops_shell_command() -> None: + """A pane sitting at a bare shell freezes to an empty pane (no nested shell).""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "main", + "pane_id": "%1", + "pane_index": "0", + "pane_current_command": "zsh", + }, + ) + pane = freeze(server).windows[0].panes[0] + assert pane.run is None + assert "zsh" in SHELLS # documents the default filter + + +def test_freeze_keeps_non_shell_command() -> None: + """A pane running a real program freezes that program as the pane command.""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "logs", + "pane_id": "%1", + "pane_index": "0", + "pane_current_command": "tail", + }, + ) + assert [c.cmd for c in freeze(server).windows[0].panes[0].commands] == ["tail"] + + +def test_freeze_selects_session_by_name() -> None: + """With many sessions, ``session=`` picks one to freeze.""" + server = _server( + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "window_name": "w", + "pane_id": "%1", + "pane_index": "0", + }, + { + "session_id": "$1", + "session_name": "b", + "window_id": "@2", + "window_index": "0", + "window_name": "w", + "pane_id": "%2", + "pane_index": "0", + }, + ) + assert freeze(server, session="b").name == "b" + assert freeze(server, session="$0").name == "a" + + +def test_freeze_ambiguous_session_raises() -> None: + """With many sessions and no selector, freeze refuses to guess.""" + server = _server( + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + "pane_index": "0", + }, + { + "session_id": "$1", + "session_name": "b", + "window_id": "@2", + "window_index": "0", + "pane_id": "%2", + "pane_index": "0", + }, + ) + with pytest.raises(ValueError, match=r"ambiguous|multiple|session="): + freeze(server) + + +def test_freeze_unknown_session_raises() -> None: + """A named session that is not present is an error, not an empty workspace.""" + server = _server( + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + "pane_index": "0", + }, + ) + with pytest.raises(ValueError, match="nope"): + freeze(server, session="nope") + + +def test_freeze_round_trips_into_a_buildable_workspace() -> None: + """``freeze`` output compiles and builds -- the declarative round-trip closes.""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "editor", + "pane_id": "%1", + "pane_index": "0", + "pane_active": "1", + "pane_current_command": "vim", + }, + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "editor", + "pane_id": "%2", + "pane_index": "1", + "pane_current_command": "tail", + }, + ) + ws = freeze(server) + assert ws.compile().operations[0].kind == "new_session" + assert ws.build(ConcreteEngine(), preflight=False).ok + + +def test_afreeze_server_captures_live_tree(session: Session) -> None: + """A real server freezes in ONE list-panes read, reproducing its windows. + + Validates ``FREEZE_FORMAT`` against live tmux: the frozen Workspace must carry + the live session's name and every window name, and remain buildable. + """ + session.new_window(window_name="logs") + live_names = {w.window_name for w in session.windows} + + async def main() -> Workspace: + engine = AsyncControlModeEngine.for_server(session.server) + try: + return await afreeze_server(engine, session=session.name) + finally: + await engine.aclose() + + ws = asyncio.run(main()) + assert ws.name == session.name + assert {w.name for w in ws.windows} == live_names + # The captured tree is a valid, buildable spec. + assert ws.compile().operations[0].kind == "new_session" From adab1763cf93376d48b746f298fb616a550dd8da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:59:42 -0500 Subject: [PATCH 119/223] Workspace(feat): Add variant expand + workspace sets why: building several near-identical sessions (per-app, per-env) meant declaring each by hand and dispatching each separately. expand() fans one workspace into named variants; WorkspaceSet folds N workspaces into one rebased plan so the whole batch builds in a single folded run. what: - Add workspace/expand.py: expand(workspace, variants) renders $name / ${name} tokens, leaving unknown tokens intact - Add workspace/sets.py: WorkspaceSet + compile_workspaces/build_workspaces /abuild_workspaces, rebasing each workspace's SlotRefs and host steps by a per-workspace offset into one LazyPlan - Export expand, WorkspaceSet(+Result/Compiled), build/abuild/compile_workspaces --- .../experimental/workspace/__init__.py | 16 + src/libtmux/experimental/workspace/expand.py | 87 ++++ src/libtmux/experimental/workspace/sets.py | 441 ++++++++++++++++++ .../contract/test_workspace_expand.py | 66 +++ .../contract/test_workspace_sets.py | 167 +++++++ 5 files changed, 777 insertions(+) create mode 100644 src/libtmux/experimental/workspace/expand.py create mode 100644 src/libtmux/experimental/workspace/sets.py create mode 100644 tests/experimental/contract/test_workspace_expand.py create mode 100644 tests/experimental/contract/test_workspace_sets.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 7eb3e9049..1e7126c39 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -38,6 +38,7 @@ WindowCreated, WorkspaceBuilt, ) +from libtmux.experimental.workspace.expand import expand from libtmux.experimental.workspace.freeze import ( afreeze_server, freeze, @@ -52,11 +53,20 @@ Workspace, ) from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace +from libtmux.experimental.workspace.sets import ( + CompiledWorkspaceSet, + WorkspaceSet, + WorkspaceSetResult, + abuild_workspaces, + build_workspaces, + compile_workspaces, +) __all__ = ( "BuildEvent", "Command", "Compiled", + "CompiledWorkspaceSet", "ConfirmReport", "Float", "FloatingPane", @@ -69,13 +79,19 @@ "Workspace", "WorkspaceBuilt", "WorkspaceCompileError", + "WorkspaceSet", + "WorkspaceSetResult", "abuild_workspace", + "abuild_workspaces", "afreeze_server", "analyze", "build_workspace", + "build_workspaces", "compile_full", "compile_workspace", + "compile_workspaces", "confirm", + "expand", "freeze", "freeze_server", ) diff --git a/src/libtmux/experimental/workspace/expand.py b/src/libtmux/experimental/workspace/expand.py new file mode 100644 index 000000000..fe9af1e48 --- /dev/null +++ b/src/libtmux/experimental/workspace/expand.py @@ -0,0 +1,87 @@ +"""Pure variant expansion for declarative workspace specs.""" + +from __future__ import annotations + +import collections.abc +import dataclasses +import re +import typing as t +from collections.abc import Callable, Iterable, Mapping + +from libtmux.experimental.workspace.ir import Workspace + +Variant: t.TypeAlias = Mapping[str, object] +NameFactory: t.TypeAlias = Callable[[str, Mapping[str, object]], str] + +_TOKEN_RE = re.compile( + r"\$(?P\$)|\$\{(?P[A-Za-z_][A-Za-z0-9_]*)\}" + r"|\$(?P[A-Za-z_][A-Za-z0-9_]*)", +) + + +def expand( + workspace: Workspace, + variants: Iterable[Mapping[str, object]], + *, + variables: Mapping[str, object] | None = None, + name: NameFactory | None = None, +) -> tuple[Workspace, ...]: + """Return one rendered workspace per variant, without mutating *workspace*. + + String fields use shell-style ``$name`` / ``${name}`` placeholders. Unknown + variables stay intact, so shell variables and tmux formats survive expansion. + + Examples + -------- + >>> from libtmux.experimental.workspace import Pane, Window, Workspace, expand + >>> base = Workspace("svc-$app", windows=[Window("$app", panes=[Pane("$cmd")])]) + >>> [ws.name for ws in expand(base, [{"app": "api", "cmd": "uvicorn"}])] + ['svc-api'] + """ + expanded: list[Workspace] = [] + for variant in variants: + context: dict[str, object] = dict(variables or {}) + context.update(variant) + rendered = t.cast("Workspace", _render(workspace, context)) + if name is not None: + rendered = dataclasses.replace( + rendered, + name=name(workspace.name, context), + ) + expanded.append(rendered) + return tuple(expanded) + + +def _render(value: t.Any, context: collections.abc.Mapping[str, object]) -> t.Any: + """Recursively render strings inside dataclasses, mappings, and sequences.""" + if isinstance(value, str): + return _render_string(value, context) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + changes = { + field.name: _render(getattr(value, field.name), context) + for field in dataclasses.fields(value) + } + return dataclasses.replace(value, **changes) + if isinstance(value, collections.abc.Mapping): + return { + _render(key, context): _render(item, context) for key, item in value.items() + } + if isinstance(value, tuple): + return tuple(_render(item, context) for item in value) + if isinstance(value, list): + return [_render(item, context) for item in value] + return value + + +def _render_string(value: str, context: collections.abc.Mapping[str, object]) -> str: + """Render known ``$name`` tokens and leave unknown shell text intact.""" + + def repl(match: re.Match[str]) -> str: + if match.group("escaped") is not None: + return "$" + key = match.group("braced") or match.group("named") + if key is None or key not in context: + return match.group(0) + return str(context[key]) + + return _TOKEN_RE.sub(repl, value) diff --git a/src/libtmux/experimental/workspace/sets.py b/src/libtmux/experimental/workspace/sets.py new file mode 100644 index 000000000..157ab2971 --- /dev/null +++ b/src/libtmux/experimental/workspace/sets.py @@ -0,0 +1,441 @@ +"""Batch declarative workspaces into one folded Core plan. + +``WorkspaceSet`` is the Declarative tier's collection primitive: a group of +workspace specs that compile into one :class:`~libtmux.experimental.ops.LazyPlan` +and therefore run through the same chainable, async-capable engine path as a +single workspace. It is deliberately still a library value -- no database, server +process, or product workflow -- so callers can layer worktrees, dashboards, or +agent launch policy outside libtmux. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops import HasSession, KillSession, LazyPlan, arun, run +from libtmux.experimental.ops._types import NameRef, SlotRef, Target +from libtmux.experimental.ops.plan import PlanResult, StepReport +from libtmux.experimental.ops.planner import BoundedPlanner, MarkedPlanner +from libtmux.experimental.workspace.compiler import Compiled, HostStep, compile_full +from libtmux.experimental.workspace.events import WorkspaceBuilt, events_for +from libtmux.experimental.workspace.expand import ( + NameFactory, + Variant, + expand, +) +from libtmux.experimental.workspace.runner import _run_host_async, _run_host_sync + +if t.TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Iterable, Mapping + + from typing_extensions import Self + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.planner import Planner + from libtmux.experimental.workspace.events import BuildEvent + from libtmux.experimental.workspace.ir import Workspace + + +@dataclass(frozen=True) +class CompiledWorkspaceSet: + """A merged workspace-set plan plus batch metadata. + + Parameters + ---------- + plan : LazyPlan + The combined Core operation spine. + host_after : Mapping[int, tuple[HostStep, ...]] + Host steps scheduled after rebased operation indices. + pre : tuple[HostStep, ...] + Host steps to run before the first operation. + sessions : tuple[str, ...] + Session names in the input order. + session_slots : Mapping[str, int] + The plan index of each workspace's ``new-session`` operation. + end_indices : Mapping[str, int] + The final operation index for each workspace. + """ + + plan: LazyPlan + host_after: Mapping[int, tuple[HostStep, ...]] = field(default_factory=dict) + pre: tuple[HostStep, ...] = () + sessions: tuple[str, ...] = () + session_slots: Mapping[str, int] = field(default_factory=dict) + end_indices: Mapping[str, int] = field(default_factory=dict) + + +@dataclass(frozen=True) +class WorkspaceSetResult: + """Result of building a workspace set.""" + + result: PlanResult + sessions: tuple[str, ...] + reused: tuple[str, ...] = () + + @property + def ok(self) -> bool: + """Whether every dispatched operation completed successfully.""" + return self.result.ok + + @property + def bindings(self) -> dict[int | tuple[int, str], str]: + """Forward-ref bindings from the underlying plan result.""" + return self.result.bindings + + def raise_for_status(self) -> Self: + """Raise on the first failed operation; return ``self`` when OK.""" + self.result.raise_for_status() + return self + + +@dataclass(frozen=True) +class WorkspaceSet: + """A collection of declared workspaces compiled and built as one unit. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.workspace import Pane, Window, Workspace + >>> ws = Workspace("dev", windows=[Window("w", panes=[Pane("echo hi")])]) + >>> WorkspaceSet((ws,)).build(ConcreteEngine(), preflight=False).ok + True + """ + + workspaces: tuple[Workspace, ...] + + def __init__(self, workspaces: Iterable[Workspace]) -> None: + object.__setattr__(self, "workspaces", _workspace_tuple(workspaces)) + + @classmethod + def from_variants( + cls, + workspace: Workspace, + variants: Iterable[Variant], + *, + variables: Mapping[str, object] | None = None, + name: NameFactory | None = None, + ) -> WorkspaceSet: + """Expand *workspace* over *variants* and wrap the rendered specs.""" + return cls(expand(workspace, variants, variables=variables, name=name)) + + def compile(self, *, version: str | None = None) -> CompiledWorkspaceSet: + """Compile this set into one rebased Core plan.""" + return compile_workspaces(self.workspaces, version=version) + + def build( + self, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, + ) -> WorkspaceSetResult: + """Build this set synchronously over *engine*.""" + return build_workspaces( + self.workspaces, + engine, + version=version, + preflight=preflight, + on_event=on_event, + planner=planner, + ) + + async def abuild( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, + ) -> WorkspaceSetResult: + """Build this set asynchronously over *engine*.""" + return await abuild_workspaces( + self.workspaces, + engine, + version=version, + preflight=preflight, + on_event=on_event, + planner=planner, + ) + + +def _workspace_tuple(workspaces: Iterable[Workspace]) -> tuple[Workspace, ...]: + """Return workspaces as a tuple, rejecting duplicate session names.""" + rows = tuple(workspaces) + seen: set[str] = set() + duplicates: list[str] = [] + for workspace in rows: + if workspace.name in seen: + duplicates.append(workspace.name) + seen.add(workspace.name) + if duplicates: + msg = f"workspace set declares duplicate sessions: {', '.join(duplicates)}" + raise ValueError(msg) + return rows + + +def _rebase_slot(ref: SlotRef, offset: int) -> SlotRef: + """Return *ref* shifted by *offset* operation slots.""" + return dataclasses.replace(ref, slot=ref.slot + offset) + + +def _rebase_target(target: Target | None, offset: int) -> Target | None: + """Shift deferred targets by *offset* while leaving concrete ids unchanged.""" + if isinstance(target, SlotRef): + return _rebase_slot(target, offset) + return target + + +def _rebase_operation(operation: Operation[t.Any], offset: int) -> Operation[t.Any]: + """Shift operation targets from a per-workspace plan into the merged plan.""" + return dataclasses.replace( + operation, + target=_rebase_target(operation.target, offset), + src_target=_rebase_target(operation.src_target, offset), + ) + + +def _rebase_host_step(step: HostStep, offset: int) -> HostStep: + """Shift the pane ref carried by a host step, if any.""" + if step.pane is None: + return step + return dataclasses.replace(step, pane=_rebase_slot(step.pane, offset)) + + +def _extend_plan(plan: LazyPlan, compiled: Compiled, offset: int) -> None: + """Append one compiled workspace to *plan* with rebased refs.""" + for operation in compiled.plan.operations: + plan.add(_rebase_operation(operation, offset)) + + +def compile_workspaces( + workspaces: Iterable[Workspace], + *, + version: str | None = None, +) -> CompiledWorkspaceSet: + """Compile multiple workspaces into one rebased Core plan. + + Examples + -------- + >>> from libtmux.experimental.workspace import Pane, Window, Workspace + >>> compiled = compile_workspaces([ + ... Workspace("a", windows=[Window("w", panes=[Pane("one")])]), + ... Workspace("b", windows=[Window("w", panes=[Pane("two")])]), + ... ]) + >>> [op.kind for op in compiled.plan.operations].count("new_session") + 2 + """ + rows = _workspace_tuple(workspaces) + plan = LazyPlan() + pre: list[HostStep] = [] + host_after: dict[int, list[HostStep]] = {} + session_slots: dict[str, int] = {} + end_indices: dict[str, int] = {} + + for workspace in rows: + offset = len(plan) + compiled = compile_full(workspace, version=version) + if offset == 0: + pre.extend(_rebase_host_step(step, offset) for step in compiled.pre) + elif compiled.pre: + host_after.setdefault(offset - 1, []).extend( + _rebase_host_step(step, offset) for step in compiled.pre + ) + + for index, steps in compiled.host_after.items(): + host_after.setdefault(index + offset, []).extend( + _rebase_host_step(step, offset) for step in steps + ) + + _extend_plan(plan, compiled, offset) + if len(compiled.plan) > 0: + session_slots[workspace.name] = offset + end_indices[workspace.name] = offset + len(compiled.plan) - 1 + + return CompiledWorkspaceSet( + plan, + {key: tuple(value) for key, value in host_after.items()}, + tuple(pre), + tuple(workspace.name for workspace in rows), + session_slots, + end_indices, + ) + + +def _preflight_sync( + workspace: Workspace, + engine: TmuxEngine, + version: str | None, +) -> bool: + """Apply one workspace's ``on_exists`` policy before a batch build.""" + exists = run(HasSession(target=NameRef(workspace.name)), engine, version=version) + if not exists.exists: + return False + if workspace.on_exists == "replace": + run(KillSession(target=NameRef(workspace.name)), engine, version=version) + return False + if workspace.on_exists == "reuse": + return True + msg = f"session {workspace.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +async def _preflight_async( + workspace: Workspace, + engine: AsyncTmuxEngine, + version: str | None, +) -> bool: + """Async sibling of :func:`_preflight_sync`.""" + result = await arun( + HasSession(target=NameRef(workspace.name)), + engine, + version=version, + ) + if not result.exists: + return False + if workspace.on_exists == "replace": + await arun(KillSession(target=NameRef(workspace.name)), engine, version=version) + return False + if workspace.on_exists == "reuse": + return True + msg = f"session {workspace.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +def _split_reused_sync( + workspaces: tuple[Workspace, ...], + engine: TmuxEngine, + version: str | None, + preflight: bool, +) -> tuple[tuple[Workspace, ...], tuple[str, ...]]: + """Return workspaces to build plus names skipped by ``on_exists='reuse'``.""" + if not preflight: + return workspaces, () + active: list[Workspace] = [] + reused: list[str] = [] + for workspace in workspaces: + if _preflight_sync(workspace, engine, version): + reused.append(workspace.name) + else: + active.append(workspace) + return tuple(active), tuple(reused) + + +async def _split_reused_async( + workspaces: tuple[Workspace, ...], + engine: AsyncTmuxEngine, + version: str | None, + preflight: bool, +) -> tuple[tuple[Workspace, ...], tuple[str, ...]]: + """Async sibling of :func:`_split_reused_sync`.""" + if not preflight: + return workspaces, () + active: list[Workspace] = [] + reused: list[str] = [] + for workspace in workspaces: + if await _preflight_async(workspace, engine, version): + reused.append(workspace.name) + else: + active.append(workspace) + return tuple(active), tuple(reused) + + +def build_workspaces( + workspaces: Iterable[Workspace], + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, +) -> WorkspaceSetResult: + """Compile and execute multiple workspaces synchronously over *engine*.""" + rows = _workspace_tuple(workspaces) + active, reused = _split_reused_sync(rows, engine, version, preflight) + if not active: + return WorkspaceSetResult( + PlanResult((), {}), + tuple(ws.name for ws in rows), + reused, + ) + + compiled = compile_workspaces(active, version=version) + ops = compiled.plan.operations + end_to_session = {index: name for name, index in compiled.end_indices.items()} + for step in compiled.pre: + _run_host_sync(step, engine, {}, version) + + def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + on_event(event) + for host_step in compiled.host_after.get(index, ()): + _run_host_sync(host_step, engine, report.bindings, version) + if on_event is not None and index in end_to_session: + slot = compiled.session_slots[end_to_session[index]] + on_event(WorkspaceBuilt(report.bindings.get(slot, ""))) + + result = compiled.plan.execute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) + return WorkspaceSetResult(result, tuple(ws.name for ws in rows), reused) + + +async def abuild_workspaces( + workspaces: Iterable[Workspace], + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, +) -> WorkspaceSetResult: + """Compile and execute multiple workspaces asynchronously over *engine*.""" + rows = _workspace_tuple(workspaces) + active, reused = await _split_reused_async(rows, engine, version, preflight) + if not active: + return WorkspaceSetResult( + PlanResult((), {}), + tuple(ws.name for ws in rows), + reused, + ) + + compiled = compile_workspaces(active, version=version) + ops = compiled.plan.operations + end_to_session = {index: name for name, index in compiled.end_indices.items()} + for step in compiled.pre: + await _run_host_async(step, engine, {}, version) + + async def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + await on_event(event) + for host_step in compiled.host_after.get(index, ()): + await _run_host_async(host_step, engine, report.bindings, version) + if on_event is not None and index in end_to_session: + slot = compiled.session_slots[end_to_session[index]] + await on_event(WorkspaceBuilt(report.bindings.get(slot, ""))) + + result = await compiled.plan.aexecute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) + return WorkspaceSetResult(result, tuple(ws.name for ws in rows), reused) diff --git a/tests/experimental/contract/test_workspace_expand.py b/tests/experimental/contract/test_workspace_expand.py new file mode 100644 index 000000000..fe7a80629 --- /dev/null +++ b/tests/experimental/contract/test_workspace_expand.py @@ -0,0 +1,66 @@ +"""Tests for pure workspace variant expansion.""" + +from __future__ import annotations + +from libtmux.experimental.workspace import Pane, Window, Workspace, expand + + +def test_expand_renders_variants_without_mutating_base() -> None: + """Expand returns one rendered workspace per variant and leaves the base pure.""" + base = Workspace( + name="svc-$app", + start_directory="${root}/$app", + environment={"APP": "$app", "UNCHANGED": "$HOME"}, + windows=[ + Window( + name="$app", + panes=[ + Pane( + run=["cd ${root}/$app", "$cmd", "echo $(pwd) #{pane_id}"], + environment={"APP": "$app"}, + ), + ], + ), + ], + ) + + expanded = expand( + base, + [ + {"app": "api", "cmd": "uvicorn app:app"}, + {"app": "worker", "cmd": "python worker.py"}, + ], + variables={"root": "/srv"}, + ) + + assert [ws.name for ws in expanded] == ["svc-api", "svc-worker"] + assert expanded[0].start_directory == "/srv/api" + assert expanded[1].windows[0].name == "worker" + assert [cmd.cmd for cmd in expanded[0].windows[0].panes[0].commands] == [ + "cd /srv/api", + "uvicorn app:app", + "echo $(pwd) #{pane_id}", + ] + assert expanded[0].environment == {"APP": "api", "UNCHANGED": "$HOME"} + assert expanded[0].windows[0].panes[0].environment == {"APP": "api"} + assert base.name == "svc-$app" + assert base.windows[0].panes[0].commands[0].cmd == "cd ${root}/$app" + + +def test_expand_name_callable_controls_workspace_name() -> None: + """A name callable can build names outside the template strings.""" + base = Workspace(name="dev", windows=[Window("py-$python", panes=[Pane("tox")])]) + + expanded = expand( + base, + [{"python": "3.12"}, {"python": "3.13"}], + name=lambda base_name, variant: f"{base_name}-py{variant['python']}", + ) + + assert [ws.name for ws in expanded] == ["dev-py3.12", "dev-py3.13"] + assert [ws.windows[0].name for ws in expanded] == ["py-3.12", "py-3.13"] + + +def test_expand_empty_variants_returns_empty_tuple() -> None: + """No variants means no expanded workspaces.""" + assert expand(Workspace(name="dev"), []) == () diff --git a/tests/experimental/contract/test_workspace_sets.py b/tests/experimental/contract/test_workspace_sets.py new file mode 100644 index 000000000..4a2f4bd37 --- /dev/null +++ b/tests/experimental/contract/test_workspace_sets.py @@ -0,0 +1,167 @@ +"""Workspace sets batch declarative builds without losing plan semantics.""" + +from __future__ import annotations + +import asyncio +import dataclasses +import typing as t + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.ops import SequentialPlanner +from libtmux.experimental.ops._types import SlotRef +from libtmux.experimental.workspace import ( + BuildEvent, + Pane, + Window, + Workspace, + WorkspaceBuilt, + WorkspaceSet, + build_workspaces, + compile_workspaces, +) + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, TmuxEngine + + +@dataclasses.dataclass +class _RecordingEngine: + """Record dispatches while forwarding to an inner engine.""" + + inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and forward, faking a ready cursor for waits.""" + self.calls.append(request.args) + if "display-message" in request.args: + return CommandResult(cmd=("tmux", *request.args), stdout=("1,1",)) + return self.inner.run(request) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def _workspace(name: str, *, wait_pane: bool = False) -> Workspace: + """Return a two-pane workspace with a command after a split.""" + return Workspace( + name=name, + windows=[ + Window( + "editor", + panes=[ + Pane(run="echo first"), + Pane(run=["echo second", "echo third"]), + ], + ), + ], + wait_pane=wait_pane, + ) + + +def test_workspace_set_from_variants_expands_base() -> None: + """WorkspaceSet.from_variants delegates to expand and preserves ordering.""" + base = Workspace(name="dev-${app}", windows=[Window("w", panes=[Pane("${cmd}")])]) + workspace_set = WorkspaceSet.from_variants( + base, + [{"app": "api", "cmd": "pytest"}, {"app": "docs", "cmd": "sphinx-build"}], + ) + + assert [ws.name for ws in workspace_set.workspaces] == ["dev-api", "dev-docs"] + assert [ + ws.windows[0].panes[0].commands[0].cmd for ws in workspace_set.workspaces + ] == ["pytest", "sphinx-build"] + + +def test_compile_workspaces_rebases_slot_refs_and_host_steps() -> None: + """Merged plans offset later workspaces' SlotRefs and host-step targets.""" + compiled = compile_workspaces( + [ + _workspace("one"), + _workspace("two", wait_pane=True), + ], + ) + first_len = len(_workspace("one").compile().operations) + second_ops = compiled.plan.operations[first_len:] + send_ops = [op for op in second_ops if op.kind == "send_keys"] + assert send_ops + deferred_targets = [op.target for op in send_ops if isinstance(op.target, SlotRef)] + assert min(target.slot for target in deferred_targets) >= first_len + + wait_steps = [ + step + for steps in compiled.host_after.values() + for step in steps + if step.kind == "wait_pane" + ] + assert wait_steps + assert all( + step.pane is not None and step.pane.slot >= first_len for step in wait_steps + ) + + +def test_build_workspaces_folds_across_workspace_boundaries() -> None: + """Batch builds still use the folding planner over the merged operation stream.""" + default = _RecordingEngine() + build_workspaces([_workspace("one"), _workspace("two")], default, preflight=False) + sequential = _RecordingEngine() + build_workspaces( + [_workspace("one"), _workspace("two")], + sequential, + preflight=False, + planner=SequentialPlanner(), + ) + + assert len(default.calls) < len(sequential.calls) + assert any(";" in argv for argv in default.calls) + + +def test_workspace_set_all_reused_returns_noop_result() -> None: + """Preflight reuse skips every existing workspace without executing the plan.""" + reused = Workspace( + name="already", + windows=[Window("w", panes=[Pane("echo nope")])], + on_exists="reuse", + ) + engine = ConcreteEngine() + + first = build_workspaces([reused], engine, preflight=False) + second = build_workspaces([reused], engine) + + assert first.ok + assert second.ok + assert second.reused == ("already",) + assert second.result.results == () + + +def test_workspace_set_emits_built_event_per_workspace() -> None: + """Each workspace emits its own WorkspaceBuilt event.""" + events: list[BuildEvent] = [] + outcome = build_workspaces( + [_workspace("one"), _workspace("two")], + ConcreteEngine(), + preflight=False, + on_event=events.append, + ) + + built = [event for event in events if isinstance(event, WorkspaceBuilt)] + assert outcome.ok + assert len(built) == 2 + + +def test_workspace_set_async_build_matches_sync_shape() -> None: + """The async runner exposes the same result shape as the sync runner.""" + workspace_set = WorkspaceSet((_workspace("one"), _workspace("two"))) + outcome = asyncio.run( + workspace_set.abuild(AsyncConcreteEngine(), preflight=False), + ) + + assert outcome.ok + assert outcome.sessions == ("one", "two") + assert len(outcome.result.results) == len( + compile_workspaces(workspace_set.workspaces).plan.operations, + ) From ce3b4fbfa6e356c021f683b2ba4689b72324b924 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:07:52 -0500 Subject: [PATCH 120/223] Ops(feat): Add plan explain() and astream() why: folding collapses N ops into a few dispatches, which hides per-op structure -- a caller can't see why the chain broke where it did, and had no way to observe a plan step by step as it ran. what: - LazyPlan.explain(planner): annotate each dispatch step with why it is a boundary (marked-fold/folded/created-id/capture/single) -- pure, no I/O - LazyPlan.astream(engine): async-generator twin of aexecute over the same drive core, yielding a StepDone per bound step and a terminal PlanDone; pull-based, so backpressure needs no buffer and the loop never blocks - Export StepExplanation, StepDone, PlanDone, PlanEvent --- src/libtmux/experimental/ops/__init__.py | 14 ++- src/libtmux/experimental/ops/plan.py | 120 ++++++++++++++++++++++- tests/experimental/ops/test_plan.py | 72 ++++++++++++++ 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 75d58453e..131020366 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -107,7 +107,15 @@ ) from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation -from libtmux.experimental.ops.plan import LazyPlan, PlanResult, StepReport +from libtmux.experimental.ops.plan import ( + LazyPlan, + PlanDone, + PlanEvent, + PlanResult, + StepDone, + StepExplanation, + StepReport, +) from libtmux.experimental.ops.planner import ( BoundedPlanner, FoldingPlanner, @@ -202,6 +210,8 @@ "PaneId", "PasteBuffer", "PipePane", + "PlanDone", + "PlanEvent", "PlanResult", "PlanStep", "Planner", @@ -241,6 +251,8 @@ "SplitWindowResult", "StartServer", "Status", + "StepDone", + "StepExplanation", "StepReport", "SuspendClient", "SwapPane", diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 8b3bb52f6..83f25ba11 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -38,7 +38,7 @@ from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict if t.TYPE_CHECKING: - from collections.abc import Generator, Iterator + from collections.abc import AsyncGenerator, Generator, Iterator from typing_extensions import Self @@ -99,6 +99,40 @@ class _Host: report: StepReport +@dataclass(frozen=True) +class StepExplanation: + """Why one dispatch step is its own tmux call (from :meth:`LazyPlan.explain`). + + ``reason`` is one of ``"marked-fold"`` (a pane create plus its ``{marked}`` + decorates), ``"folded"`` (a ``;``-chain of chainable ops), ``"created-id"`` + (a create whose captured id a later op must target -- a true blocker), + ``"capture"`` (a non-chainable op whose stdout can't merge into a chain), or + ``"single"`` (a lone chainable op with nothing to fold with). + """ + + step: PlanStep + kinds: tuple[str, ...] + reason: str + + +@dataclass(frozen=True) +class StepDone: + """Stream event: a plan step finished and its results have bound.""" + + report: StepReport + + +@dataclass(frozen=True) +class PlanDone: + """Stream event: the plan finished; carries the full :class:`PlanResult`.""" + + result: PlanResult + + +#: An event yielded by :meth:`LazyPlan.astream`. +PlanEvent = StepDone | PlanDone + + def _target_from_id(value: str) -> Target: """Map a captured concrete id back to its typed target.""" if value.startswith("%"): @@ -275,6 +309,45 @@ def _render(op: Operation[t.Any]) -> tuple[str, ...] | None: return [_render(op) for op in self._operations] + def explain(self, planner: Planner | None = None) -> list[StepExplanation]: + """Explain why *planner* breaks the plan into the dispatches it does. + + A pure companion to :meth:`preview`: folding hides per-op structure, so + this annotates each dispatch step with the reason it can't fold further + (see :class:`StepExplanation`). Defaults to + :class:`~.planner.SequentialPlanner`. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys, MarkedPlanner + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) + >>> _ = plan.add(SendKeys(target=pane, keys="vim")) + >>> [(e.kinds, e.reason) for e in plan.explain(MarkedPlanner())] + [(('split_window', 'send_keys'), 'marked-fold')] + >>> [(e.kinds, e.reason) for e in plan.explain()] + [(('split_window',), 'created-id'), (('send_keys',), 'single')] + """ + steps = (planner or SequentialPlanner()).plan(self._operations) + out: list[StepExplanation] = [] + for step in steps: + kinds = tuple(self._operations[i].kind for i in step.indices) + if step.marked: + reason = "marked-fold" + elif len(step.indices) > 1: + reason = "folded" + else: + op = self._operations[step.indices[0]] + if op.effects.creates is not None: + reason = "created-id" + elif not op.chainable: + reason = "capture" + else: + reason = "single" + out.append(StepExplanation(step, kinds, reason)) + return out + def _drive( self, version: str | None, @@ -413,3 +486,48 @@ async def _adispatch( if isinstance(request, _Chain): return await engine.run(CommandRequest.from_args(*request.argv)) return await arun(request.op, engine, version=version) + + async def astream( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> AsyncGenerator[PlanEvent, None]: + """Execute the plan, streaming a :data:`PlanEvent` per step as it binds. + + The observe-as-you-go twin of :meth:`aexecute` over the same sans-I/O + resolution core: it yields a :class:`StepDone` after each dispatch binds + and a terminal :class:`PlanDone` carrying the full :class:`PlanResult`, so + ``[e async for e in plan.astream(engine)][-1].result`` equals ``await + plan.aexecute(engine)``. The stream is pull-based -- a slow ``async for`` + naturally paces the plan, so backpressure needs no buffer and the event + loop is never blocked between dispatches. Run one ``astream`` per engine + at a time (the engine's write order is shared). + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> plan = LazyPlan() + >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) + >>> async def drain() -> list[str]: + ... engine = AsyncConcreteEngine() + ... return [type(e).__name__ async for e in plan.astream(engine)] + >>> asyncio.run(drain()) + ['StepDone', 'PlanDone'] + """ + version = resolve_engine_version(engine, version) + gen = self._drive(version, planner or SequentialPlanner()) + try: + request = next(gen) + while True: + if isinstance(request, _Host): + yield StepDone(request.report) # pull point: consumer paces here + request = gen.send(None) + else: + request = gen.send(await self._adispatch(request, engine, version)) + except StopIteration as stop: + yield PlanDone(t.cast("PlanResult", stop.value)) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index cb5a8f24b..43ce28ebc 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -211,3 +211,75 @@ def test_plan_unresolvable_ref_fails_closed() -> None: assert exc_info.value.slot == 0 # points at the non-capturing creator # ForwardCaptureError stays an OperationError, so broad handlers keep working assert isinstance(exc_info.value, OperationError) + + +class _ExplainCase(t.NamedTuple): + """A planner and the (kinds, reason) each of its dispatch steps should carry.""" + + test_id: str + planner: t.Any + expected: list[tuple[tuple[str, ...], str]] + + +def _split_then_send() -> LazyPlan: + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim")) + return plan + + +_EXPLAIN_CASES: tuple[_ExplainCase, ...] = ( + _ExplainCase( + "sequential_created_then_single", + SequentialPlanner(), + [(("split_window",), "created-id"), (("send_keys",), "single")], + ), + _ExplainCase( + "marked_fold", + MarkedPlanner(), + [(("split_window", "send_keys"), "marked-fold")], + ), +) + + +@pytest.mark.parametrize( + "case", + _EXPLAIN_CASES, + ids=[c.test_id for c in _EXPLAIN_CASES], +) +def test_explain_annotates_dispatch_boundaries(case: _ExplainCase) -> None: + """explain() reports why each step is its own dispatch under a planner.""" + steps = _split_then_send().explain(case.planner) + assert [(e.kinds, e.reason) for e in steps] == case.expected + + +def test_astream_yields_step_then_plan_done() -> None: + """astream() streams a StepDone per step and a terminal PlanDone.""" + from libtmux.experimental.ops import PlanDone, StepDone + + plan = _split_then_send() + + async def drain() -> list[object]: + return [event async for event in plan.astream(AsyncConcreteEngine())] + + events = asyncio.run(drain()) + assert [type(e).__name__ for e in events] == ["StepDone", "StepDone", "PlanDone"] + assert isinstance(events[-1], PlanDone) + assert isinstance(events[0], StepDone) + # the terminal PlanDone carries the same result aexecute() would return + assert events[-1].result.ok + + +def test_astream_last_result_matches_aexecute() -> None: + """The terminal PlanDone.result equals what aexecute() returns.""" + from libtmux.experimental.ops import PlanDone + + async def both() -> tuple[bool, bool]: + streamed = [e async for e in _split_then_send().astream(AsyncConcreteEngine())] + direct = await _split_then_send().aexecute(AsyncConcreteEngine()) + last = streamed[-1] + assert isinstance(last, PlanDone) + return last.result.ok, direct.ok + + stream_ok, direct_ok = asyncio.run(both()) + assert stream_ok == direct_ok From a434cc653b67aab63f58d0f60df93933e365d3fe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:27:05 -0500 Subject: [PATCH 121/223] Fluent(feat): Add sleep/wait host boundaries why: some builds need a real pause between dispatches -- a fixed delay, or waiting for a pane's shell prompt before sending the next command. A host step is a true blocker: it can't fold into a tmux chain, so the fold must break around it. what: - PlanBuilder.sleep(seconds): pause after the last recorded op - PlanBuilder.wait(pane): poll the pane's cursor until its prompt draws - run()/arun() bound the planner (BoundedPlanner) at each host boundary and run the recorded step from the on_step hook, sync and async --- src/libtmux/experimental/fluent.py | 118 +++++++++++++++++++++++++++-- tests/experimental/test_fluent.py | 48 ++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index 5641287aa..b0e6bf671 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -31,22 +31,47 @@ from __future__ import annotations +import asyncio +import time import typing as t from dataclasses import dataclass, field from libtmux.experimental.ops import ( + BoundedPlanner, + DisplayMessage, LazyPlan, MarkedPlanner, NameRef, NewSession, NewWindow, + arun, + run, ) -from libtmux.experimental.query import ForwardPaneRef +from libtmux.experimental.ops.plan import _resolve +from libtmux.experimental.query import ForwardPaneRef, _PaneRefBase if t.TYPE_CHECKING: from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine - from libtmux.experimental.ops import Planner, PlanResult - from libtmux.experimental.ops._types import SlotRef + from libtmux.experimental.ops import Planner, PlanResult, StepReport + from libtmux.experimental.ops._types import SlotRef, Target + +_CURSOR_FMT = "#{cursor_x},#{cursor_y}" +_WAIT_PANE_POLLS = 40 +_WAIT_PANE_INTERVAL = 0.05 + + +def _pane_ready(cursor: str) -> bool: + """Whether a pane's cursor has left the origin (its shell prompt drew).""" + return bool(cursor) and cursor != "0,0" + + +@dataclass(frozen=True) +class _HostAction: + """A host-side pause recorded after an operation (a hard fold boundary).""" + + kind: t.Literal["sleep", "wait"] + seconds: float = 0.0 + pane: Target | None = None @dataclass(frozen=True) @@ -116,6 +141,7 @@ class PlanBuilder: """A fluent recorder over a :class:`LazyPlan`; :meth:`run` folds by default.""" plan: LazyPlan = field(default_factory=LazyPlan) + _host_after: dict[int, list[_HostAction]] = field(default_factory=dict) def new_session(self, name: str) -> SessionRef: """Create a session, capturing its first pane for forward refs. @@ -132,6 +158,53 @@ def new_session(self, name: str) -> SessionRef: slot = self.plan.add(NewSession(session_name=name, capture_panes=True)) return SessionRef(self.plan, name, slot) + def sleep(self, seconds: float) -> PlanBuilder: + """Pause *seconds* after the last recorded op (a hard fold boundary). + + A host step never folds into a ``tmux`` dispatch, so the chain breaks + before and after it; the pause runs between dispatches at build time. + + Examples + -------- + >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> p = plan() + >>> pane = p.new_session("dev").window().pane() + >>> _ = pane.do(lambda c: c.send_keys("slow-start")) + >>> p.sleep(0.0).run(ConcreteEngine()).ok + True + """ + self._record_host(_HostAction("sleep", seconds=seconds)) + return self + + def wait(self, pane: _PaneRefBase) -> PlanBuilder: + """Wait for *pane*'s shell prompt before the next dispatch (anti-race). + + Polls the pane's cursor until it leaves the origin, so a follow-up + command isn't sent before the shell is ready. A hard fold boundary. + + Examples + -------- + >>> p = plan() + >>> pane = p.new_session("dev").window().pane() + >>> p.wait(pane) is p + True + """ + self._record_host(_HostAction("wait", pane=pane.target)) + return self + + def _record_host(self, action: _HostAction) -> None: + """Record *action* after the plan's current last operation.""" + index = len(self.plan.operations) - 1 + if index >= 0: + self._host_after.setdefault(index, []).append(action) + + def _planner(self, planner: Planner | None) -> Planner: + """Return the base planner, bounded by host-step boundaries if any.""" + base = planner or MarkedPlanner() + if self._host_after: + return BoundedPlanner(base, frozenset(self._host_after)) + return base + def run( self, engine: TmuxEngine, @@ -149,10 +222,26 @@ def run( >>> p.run(ConcreteEngine()).ok True """ + + def on_step(report: StepReport) -> None: + for action in self._host_after.get(report.step.indices[-1], ()): + if action.kind == "sleep": + time.sleep(action.seconds) + elif action.pane is not None: + op = _resolve( + DisplayMessage(target=action.pane, message=_CURSOR_FMT), + report.bindings, + ) + for _ in range(_WAIT_PANE_POLLS): + if _pane_ready(run(op, engine, version=version).text): + break + time.sleep(_WAIT_PANE_INTERVAL) + return self.plan.execute( engine, version=version, - planner=planner or MarkedPlanner(), + planner=self._planner(planner), + on_step=on_step, ) async def arun( @@ -162,11 +251,28 @@ async def arun( version: str | None = None, planner: Planner | None = None, ) -> PlanResult: - """Async twin of :meth:`run` (same fold, ``await``ed).""" + """Async twin of :meth:`run` (same fold and host steps, ``await``ed).""" + + async def on_step(report: StepReport) -> None: + for action in self._host_after.get(report.step.indices[-1], ()): + if action.kind == "sleep": + await asyncio.sleep(action.seconds) + elif action.pane is not None: + op = _resolve( + DisplayMessage(target=action.pane, message=_CURSOR_FMT), + report.bindings, + ) + for _ in range(_WAIT_PANE_POLLS): + result = await arun(op, engine, version=version) + if _pane_ready(result.text): + break + await asyncio.sleep(_WAIT_PANE_INTERVAL) + return await self.plan.aexecute( engine, version=version, - planner=planner or MarkedPlanner(), + planner=self._planner(planner), + on_step=on_step, ) def preview(self, *, version: str | None = None) -> list[tuple[str, ...] | None]: diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index 8e596b7a0..f126d328d 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -87,3 +87,51 @@ def test_build_session_live(session: Session) -> None: built = [s for s in session.server.sessions if s.session_name == "fluentdev"] assert len(built) == 1 assert len(built[0].windows[0].panes) == 2 + + +class _HostCase(t.NamedTuple): + """A host boundary recorded on the builder and its recorded action kind.""" + + test_id: str + record: t.Callable[[PlanBuilder, ForwardPaneRef], object] + kind: str + + +_HOST_CASES: tuple[_HostCase, ...] = ( + _HostCase("sleep", lambda p, _pane: p.sleep(0.0), "sleep"), + _HostCase("wait", lambda p, pane: p.wait(pane), "wait"), +) + + +@pytest.mark.parametrize("case", _HOST_CASES, ids=[c.test_id for c in _HOST_CASES]) +def test_host_step_recorded_after_last_op(case: _HostCase) -> None: + """sleep()/wait() record a host action keyed to the current last op.""" + p = plan() + pane = p.new_session("dev").window().pane() + case.record(p, pane) + assert list(p._host_after) == [0] # after new_session, the only op so far + assert len(p._host_after[0]) == 1 + assert p._host_after[0][0].kind == case.kind + + +def test_host_boundary_prevents_fold_across_it() -> None: + """No dispatch step may span a recorded host boundary (a true blocker).""" + p = plan() + pane = p.new_session("dev").window().pane() + pane.do(lambda c: c.send_keys("a")) # op 1 + p.sleep(0.0) # boundary after op 1 + pane.do(lambda c: c.send_keys("b")) # op 2 + + steps = p._planner(None).plan(p.plan.operations) + spanning = [s for s in steps if min(s.indices) <= 1 < max(s.indices)] + assert not spanning # nothing folds across the boundary at index 1 + + +def test_sleep_runs_offline() -> None: + """A build with a sleep boundary resolves and runs over the in-memory engine.""" + p = plan() + pane = p.new_session("dev").window().pane() + pane.do(lambda c: c.send_keys("vim")) + p.sleep(0.0) + pane.split().do(lambda c: c.send_keys("htop")) + assert p.run(ConcreteEngine()).ok From 72b45a76d42268ef3b1e2b34dc7b33291e3a5dd8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:33:08 -0500 Subject: [PATCH 122/223] Mcp(feat): Add explain_plan tool why: an agent composing a plan over MCP could preview argv and query a result schema, but couldn't see why a planner folds or breaks the plan into the dispatches it does -- the fold hides that structure. what: - plan_tools.explain_plan(plan, planner): per-step boundary reasons (marked-fold/folded/created-id/capture/single), pure over a serialized plan - Register a readonly explain_plan tool in the fastmcp adapter - Export explain_plan from the mcp package --- src/libtmux/experimental/mcp/__init__.py | 2 ++ .../experimental/mcp/fastmcp_adapter.py | 12 +++++++++ src/libtmux/experimental/mcp/plan_tools.py | 27 +++++++++++++++++++ tests/experimental/mcp/test_mcp_projection.py | 14 ++++++++++ 4 files changed, 55 insertions(+) diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 03e33ab6f..074e1254a 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -35,6 +35,7 @@ aexecute_plan, build_workspace, execute_plan, + explain_plan, preview_plan, result_schema, ) @@ -286,6 +287,7 @@ def main(argv: Sequence[str] | None = None) -> None: "default_async_server", "default_server", "execute_plan", + "explain_plan", "kill_pane", "kill_session", "kill_window", diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 19589017a..0c2155604 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -459,6 +459,17 @@ def preview_plan( "argv": [list(item) if item is not None else None for item in preview.argv], } + def explain_plan( + operations: list[dict[str, t.Any]], + planner: str = "marked", + ) -> dict[str, t.Any]: + """Explain why *planner* folds or breaks a serialized plan (pure).""" + explanation = _plan.explain_plan( + _plan_from_dicts(operations), + planner=_planner(planner), + ) + return {"steps": explanation.steps} + def result_schema(kind: str) -> dict[str, t.Any]: """Report what an operation kind returns, for planning forward refs.""" schema = _plan.result_schema(reg, kind) @@ -471,6 +482,7 @@ def result_schema(kind: str) -> dict[str, t.Any]: tools: list[tuple[Callable[..., t.Any], str]] = [ (preview_plan, "readonly"), + (explain_plan, "readonly"), (result_schema, "readonly"), ] diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py index 49da2edee..fb503af08 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -45,6 +45,33 @@ def preview_plan(plan: LazyPlan, *, version: str | None = None) -> PlanPreview: ) +@dataclass(frozen=True) +class PlanExplanation: + """Why a planner breaks a plan into its dispatch steps: one dict per step. + + Each entry carries ``indices`` (the operation indices in the step), + ``kinds`` (their operation kinds), and ``reason`` (the boundary reason -- + ``marked-fold`` / ``folded`` / ``created-id`` / ``capture`` / ``single``), so + an agent can see why a chain folds or breaks before it runs. + """ + + steps: list[dict[str, t.Any]] + + +def explain_plan(plan: LazyPlan, *, planner: Planner | None = None) -> PlanExplanation: + """Explain a plan's dispatch grouping under *planner* (pure, no engine).""" + return PlanExplanation( + steps=[ + { + "indices": list(entry.step.indices), + "kinds": list(entry.kinds), + "reason": entry.reason, + } + for entry in plan.explain(planner) + ], + ) + + @dataclass(frozen=True) class PlanOutcome: """The result of executing a plan: per-op result dicts + a bindings map.""" diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 4bb50d2e1..8adfab6a3 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -13,12 +13,14 @@ OperationToolRegistry, build_workspace, execute_plan, + explain_plan, preview_plan, resolve_target, result_schema, ) from libtmux.experimental.ops import ( LazyPlan, + MarkedPlanner, NewSession, SendKeys, SplitWindow, @@ -107,6 +109,18 @@ def test_preview_plan_marks_unresolved_forward_refs() -> None: assert preview.ok is False +def test_explain_plan_reports_boundary_reasons() -> None: + """explain_plan annotates each dispatch step with why it can't fold further.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + steps = explain_plan(plan, planner=MarkedPlanner()).steps + assert len(steps) == 1 + assert steps[0]["indices"] == [0, 1] + assert steps[0]["kinds"] == ["split_window", "send_keys"] + assert steps[0]["reason"] == "marked-fold" + + def test_execute_plan_returns_bindings() -> None: """execute_plan resolves forward refs and returns a JSON bindings map.""" plan = LazyPlan() From 5ed6ae1951c9c5873b6f7c88229c72ca66e966c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:50:39 -0500 Subject: [PATCH 123/223] Ops(feat): Add conditional find-or-create via ensure() why: rebuilding a workspace should reuse a session/window that already exists rather than error or duplicate it. Doing that in the pure, serializable plan model means the "does it exist?" branch has to live at execution time, not build time. what: - LazyPlan.ensure(index, probe): mark a create conditional -- the driver runs the probe (a read returning the object's capture format), and on success binds the slot to the found ids and skips the create - Reuse the create op's own build_result to parse the probe's ids, so a found object binds the same self/window/pane subrefs a created one would - Carry the probe through to_list/from_list so the conditional round-trips - Cover found-reuses / absent-creates and the serialization round-trip --- src/libtmux/experimental/ops/plan.py | 56 +++++++++++++++++-- tests/experimental/ops/test_plan.py | 80 +++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 83f25ba11..f7bd21b6d 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -245,6 +245,7 @@ class LazyPlan: def __init__(self) -> None: self._operations: list[Operation[t.Any]] = [] + self._ensures: dict[int, Operation[t.Any]] = {} def add(self, operation: Operation[t.Any]) -> SlotRef: """Record an operation; return a :class:`SlotRef` to its eventual id. @@ -255,6 +256,20 @@ def add(self, operation: Operation[t.Any]) -> SlotRef: self._operations.append(operation) return SlotRef(len(self._operations) - 1) + def ensure(self, index: int, probe: Operation[t.Any]) -> None: + """Make the create at *index* conditional: probe first, create only if absent. + + At execution the driver runs *probe* (a read that returns the object's + capture format -- e.g. ``display-message`` yielding ``#{session_id} ...``); + if it succeeds the create is *skipped* and the slot binds to the found + ids, so a find-or-create build reuses an existing object. The plan stays a + flat, serializable list of operations -- the branch lives in the driver. + The create at *index* must be a non-chainable create (so it is its own + dispatch step); *probe* must render the same capture format the create + captures, so :meth:`Operation.build_result` parses the found ids. + """ + self._ensures[index] = probe + @property def operations(self) -> tuple[Operation[t.Any], ...]: """The recorded operations, in order.""" @@ -269,14 +284,30 @@ def __iter__(self) -> Iterator[Operation[t.Any]]: return iter(self._operations) def to_list(self) -> list[dict[str, t.Any]]: - """Serialize the whole plan to a list of plain operation dicts.""" - return [operation_to_dict(operation) for operation in self._operations] + """Serialize the whole plan to a list of plain operation dicts. + + A find-or-create op (see :meth:`ensure`) carries its probe under an + ``"ensure"`` key so the conditional survives the round-trip. + """ + out: list[dict[str, t.Any]] = [] + for index, operation in enumerate(self._operations): + item = operation_to_dict(operation) + probe = self._ensures.get(index) + if probe is not None: + item["ensure"] = operation_to_dict(probe) + out.append(item) + return out @classmethod def from_list(cls, data: t.Sequence[t.Mapping[str, t.Any]]) -> LazyPlan: - """Reconstruct a plan from :meth:`to_list` output.""" + """Reconstruct a plan from :meth:`to_list` output (probes included).""" plan = cls() - plan._operations = [operation_from_dict(item) for item in data] + for index, item in enumerate(data): + rest = {key: value for key, value in item.items() if key != "ensure"} + plan._operations.append(operation_from_dict(rest)) + probe = item.get("ensure") + if probe is not None: + plan._ensures[index] = operation_from_dict(probe) return plan def add_chain(self, chain: OpChain) -> None: @@ -388,7 +419,22 @@ def _drive( bindings[create_idx] = new_id elif len(step.indices) == 1: index = step.indices[0] - result = yield _Single(_resolve(self._operations[index], bindings)) + probe = self._ensures.get(index) + if probe is not None: + found = yield _Single(_resolve(probe, bindings)) + if found.ok and found.text.strip(): + # The object exists: bind to its ids, skip the create. + result = self._operations[index].build_result( + returncode=0, + stdout=(found.text,), + version=version, + ) + else: + result = yield _Single( + _resolve(self._operations[index], bindings), + ) + else: + result = yield _Single(_resolve(self._operations[index], bindings)) results[index] = result if result.created_id is not None: bindings[index] = result.created_id diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 43ce28ebc..e311ed2c9 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -8,22 +8,28 @@ import pytest from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import ( BreakPane, + DisplayMessage, JoinPane, LazyPlan, MarkedPlanner, MovePane, + NewSession, SendKeys, SequentialPlanner, SplitWindow, StepReport, SwapPane, ) -from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId +from libtmux.experimental.ops._types import NameRef, PaneId, SlotRef, WindowId from libtmux.experimental.ops.exc import ForwardCaptureError, OperationError if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest from libtmux.experimental.ops.operation import Operation @@ -283,3 +289,75 @@ async def both() -> tuple[bool, bool]: stream_ok, direct_ok = asyncio.run(both()) assert stream_ok == direct_ok + + +class _FindEngine: + """A fake engine where the probe reports found-or-not and the create makes one.""" + + def __init__(self, *, found: bool) -> None: + self.found = found + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Answer a display-message probe, or a new-session create.""" + self.calls.append(request.args) + cmd = ("tmux", *request.args) + if request.args[0] == "display-message": + if self.found: + return CommandResult(cmd=cmd, stdout=("$9 @9 %9",), returncode=0) + return CommandResult(cmd=cmd, stderr=("no session",), returncode=1) + return CommandResult(cmd=cmd, stdout=("$1 @1 %1",), returncode=0) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Run each request in order.""" + return [self.run(req) for req in requests] + + +class _EnsureCase(t.NamedTuple): + """Whether the probe finds the object, and the id + create-count expected.""" + + test_id: str + found: bool + session_id: str + creates: int + + +_ENSURE_CASES: tuple[_EnsureCase, ...] = ( + _EnsureCase("found_reuses", found=True, session_id="$9", creates=0), + _EnsureCase("absent_creates", found=False, session_id="$1", creates=1), +) + + +@pytest.mark.parametrize("case", _ENSURE_CASES, ids=[c.test_id for c in _ENSURE_CASES]) +def test_ensure_probes_then_creates_only_if_absent(case: _EnsureCase) -> None: + """An ensured create binds a found object's ids, or creates when absent.""" + plan = LazyPlan() + slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.ensure( + slot.slot, + DisplayMessage(target=NameRef("dev"), message="#{session_id}"), + ) + engine = _FindEngine(found=case.found) + result = plan.execute(engine) + + assert result.ok + assert result.bindings[0] == case.session_id + assert result.bindings[0, "pane"].startswith("%") # first-pane subref bound + creates = [call for call in engine.calls if call[0] == "new-session"] + assert len(creates) == case.creates # created only when the probe found nothing + + +def test_ensure_survives_serialization_round_trip() -> None: + """to_list/from_list carry an ensured op's probe, so the conditional persists.""" + plan = LazyPlan() + slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.ensure( + slot.slot, DisplayMessage(target=NameRef("dev"), message="#{session_id}") + ) + + revived = LazyPlan.from_list(plan.to_list()) + + assert revived.operations == plan.operations + engine = _FindEngine(found=True) + assert revived.execute(engine).bindings[0] == "$9" + assert not [call for call in engine.calls if call[0] == "new-session"] From 11f04ee62cfbb8b0ecd523e66a997e365bce4ec2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:50:46 -0500 Subject: [PATCH 124/223] Fluent(feat): Add find_or_create_session why: give the fluent builder an idempotent session entry, so re-running a build reuses the live session instead of duplicating it -- the tmuxp load -a shape, in the forward-ref builder. what: - PlanBuilder.find_or_create_session(name): record the same create as new_session, made conditional via LazyPlan.ensure with a display-message probe that captures the session's id, first window, and first pane - Cover the recorded shape and a live idempotent double-build --- src/libtmux/experimental/fluent.py | 30 ++++++++++++++++++++++++++++++ tests/experimental/test_fluent.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index b0e6bf671..265426f4c 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -58,6 +58,10 @@ _CURSOR_FMT = "#{cursor_x},#{cursor_y}" _WAIT_PANE_POLLS = 40 _WAIT_PANE_INTERVAL = 0.05 +#: The probe format for a session find-or-create -- the ids +#: ``NewSession(capture_panes=True)`` captures, so a found session binds the +#: same self/window/pane slots a created one would. +_SESSION_PROBE = "#{session_id} #{window_id} #{pane_id}" def _pane_ready(cursor: str) -> bool: @@ -158,6 +162,32 @@ def new_session(self, name: str) -> SessionRef: slot = self.plan.add(NewSession(session_name=name, capture_panes=True)) return SessionRef(self.plan, name, slot) + def find_or_create_session(self, name: str) -> SessionRef: + """Reach session *name*, creating it only if it does not exist. + + At build time this records the same create as :meth:`new_session`, but + makes it conditional (see :meth:`~..ops.plan.LazyPlan.ensure`): at + execution the plan probes for *name* and reuses the live session when it + is already there, so a re-run is idempotent instead of a duplicate. + + Examples + -------- + >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> p = plan() + >>> _ = p.find_or_create_session("dev").window().pane() + >>> [op.kind for op in p.plan.operations] + ['new_session'] + >>> p.run(ConcreteEngine()).ok + True + """ + create = NewSession(session_name=name, capture_panes=True) + slot = self.plan.add(create) + self.plan.ensure( + slot.slot, + DisplayMessage(target=NameRef(name), message=_SESSION_PROBE), + ) + return SessionRef(self.plan, name, slot) + def sleep(self, seconds: float) -> PlanBuilder: """Pause *seconds* after the last recorded op (a hard fold boundary). diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index f126d328d..742a4d690 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -135,3 +135,32 @@ def test_sleep_runs_offline() -> None: p.sleep(0.0) pane.split().do(lambda c: c.send_keys("htop")) assert p.run(ConcreteEngine()).ok + + +def test_find_or_create_session_records_a_conditional_create() -> None: + """find_or_create_session records one create, made conditional via ensure.""" + p = plan() + pane = p.find_or_create_session("dev").window().pane() + pane.do(lambda c: c.send_keys("vim")) + assert [op.kind for op in p.plan.operations] == ["new_session", "send_keys"] + assert 0 in p.plan._ensures # the create is conditional + + +def test_find_or_create_session_is_idempotent_live(session: Session) -> None: + """Building the same session name twice reuses it instead of duplicating.""" + from libtmux.experimental.engines.subprocess import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + def build() -> None: + p = plan() + p.find_or_create_session("fluent-idem").window().pane().do( + lambda c: c.send_keys("echo hi", enter=False), + ) + p.run(engine).raise_for_status() + + build() + build() # second run must find the existing session, not create a duplicate + + named = [s for s in session.server.sessions if s.session_name == "fluent-idem"] + assert len(named) == 1 From 302ec17cdb05209fe6e41880dac1b9baede3077f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 14:22:32 -0500 Subject: [PATCH 125/223] Test(fix): Clean up sessions in fluent live tests why: the fluent live tests created sessions on the session-scoped shared server and never removed them. On tmux 3.3a/3.5 under xdist, that leaked session perturbed the phantom-reap tests, which assert an exact global session count -- CI failed there though the local gate (newer tmux) did not. what: - Kill the created session in a finally block (test_build_session_live, test_find_or_create_session_is_idempotent_live) via a _kill_named helper --- tests/experimental/test_fluent.py | 50 +++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index 742a4d690..28b930bd4 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -11,6 +11,7 @@ from libtmux.experimental.query import ForwardPaneRef if t.TYPE_CHECKING: + from libtmux.server import Server from libtmux.session import Session @@ -72,21 +73,36 @@ def test_window_pane_is_forward_handle() -> None: assert not hasattr(ref, "pane_id") +def _kill_named(server: Server, name: str) -> None: + """Kill every session named *name* so a live test leaves the shared server clean. + + The ``server`` fixture is session-scoped, so a leaked session would perturb + later tests that measure global session counts (e.g. the phantom-reap tests). + """ + for sess in server.sessions: + if sess.session_name == name: + sess.kill() + + def test_build_session_live(session: Session) -> None: """A fluent build creates a real session with the declared panes.""" from libtmux.experimental.engines.subprocess import SubprocessEngine - engine = SubprocessEngine.for_server(session.server) - p = plan() - pane = p.new_session("fluentdev").window().pane() - pane.do(lambda c: c.send_keys("echo top", enter=False)).split().do( - lambda c: c.send_keys("echo bottom", enter=False), - ) - p.run(engine).raise_for_status() + server = session.server + engine = SubprocessEngine.for_server(server) + try: + p = plan() + pane = p.new_session("fluentdev").window().pane() + pane.do(lambda c: c.send_keys("echo top", enter=False)).split().do( + lambda c: c.send_keys("echo bottom", enter=False), + ) + p.run(engine).raise_for_status() - built = [s for s in session.server.sessions if s.session_name == "fluentdev"] - assert len(built) == 1 - assert len(built[0].windows[0].panes) == 2 + built = [s for s in server.sessions if s.session_name == "fluentdev"] + assert len(built) == 1 + assert len(built[0].windows[0].panes) == 2 + finally: + _kill_named(server, "fluentdev") class _HostCase(t.NamedTuple): @@ -150,7 +166,8 @@ def test_find_or_create_session_is_idempotent_live(session: Session) -> None: """Building the same session name twice reuses it instead of duplicating.""" from libtmux.experimental.engines.subprocess import SubprocessEngine - engine = SubprocessEngine.for_server(session.server) + server = session.server + engine = SubprocessEngine.for_server(server) def build() -> None: p = plan() @@ -159,8 +176,11 @@ def build() -> None: ) p.run(engine).raise_for_status() - build() - build() # second run must find the existing session, not create a duplicate + try: + build() + build() # second run must find the existing session, not create a duplicate - named = [s for s in session.server.sessions if s.session_name == "fluent-idem"] - assert len(named) == 1 + named = [s for s in server.sessions if s.session_name == "fluent-idem"] + assert len(named) == 1 + finally: + _kill_named(server, "fluent-idem") From 9e557e4ac22f17069b998060f405494a584efa82 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 14:50:49 -0500 Subject: [PATCH 126/223] docs(experimental): Document the fluent plan() builder why: the fluent forward-ref build tier had only method/module doctests; the experimental page covered the Core ops/engines/plans but not the declarative surface a user reaches for first. what: - Add a "Building fluently with plan()" section to the experimental page, proportional to the other sections: forward-ref handles, fold-unless- true-blocker, and find_or_create_session, with runnable ConcreteEngine doctests --- docs/experimental.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/experimental.md b/docs/experimental.md index 0cc6fbe65..83ebcf439 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -115,6 +115,45 @@ many times tmux is invoked: True ``` +## Building fluently with `plan()` + +{func}`~libtmux.experimental.fluent.plan` is a fluent builder over a plan: you +name a session, walk down to a pane, and record what each pane runs, without +threading the new ids through yourself. Nothing touches tmux until +{meth}`~libtmux.experimental.fluent.PlanBuilder.run`, which folds the whole +description into a few dispatches (its async twin is ``arun``): + +```python +>>> from libtmux.experimental.fluent import plan +>>> from libtmux.experimental.engines import ConcreteEngine +>>> p = plan() +>>> pane = p.new_session("dev").window().pane() +>>> _ = pane.do(lambda c: c.send_keys("vim")).split().do(lambda c: c.send_keys("htop")) +>>> p.run(ConcreteEngine()).ok +True +``` + +``.split()`` makes a new pane that does not exist yet, so it comes back as a +*forward* handle: you keep building on it, but reading its id is a static type +error (the concrete {class}`~libtmux.experimental.query.PaneRef` has +``.pane_id``; the {class}`~libtmux.experimental.query.ForwardPaneRef` does not), +resolved against the captured id only when the plan runs. + +``run()`` folds by default and breaks the fold only at a true blocker -- a +created id a later op needs, or a host pause recorded by ``sleep()``/``wait()``. +{meth}`~libtmux.experimental.fluent.PlanBuilder.find_or_create_session` makes the +create conditional, so re-running a build reuses a live session instead of +duplicating it: + +```python +>>> from libtmux.experimental.fluent import plan +>>> from libtmux.experimental.engines import ConcreteEngine +>>> p = plan() +>>> _ = p.find_or_create_session("dev").window().pane() +>>> p.run(ConcreteEngine()).ok +True +``` + ## Operation catalog The catalog below is generated from the operation registry, so it always matches From 5fed520c2b42e5dff487a920af0a11833244df49 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 15:12:44 -0500 Subject: [PATCH 127/223] Mcp(fix): Preserve ensure across plan-tool serialization why: the MCP plan tools rebuilt a serialized plan with add() + operation_from_dict, which drops the `ensure` probe that to_list emits -- so a find-or-create plan round-tripped through MCP silently became an unconditional create (a duplicate-session footgun). what: - Route _plan_from_dicts through LazyPlan.from_list, which carries the ensure probe, so a conditional create survives the MCP round-trip --- src/libtmux/experimental/mcp/fastmcp_adapter.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 0c2155604..e35d77c9c 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -425,7 +425,6 @@ def register_plan_tools( Planner, SequentialPlanner, ) - from libtmux.experimental.ops.serialize import operation_from_dict reg = registry if registry is not None else OperationToolRegistry() planners: dict[str, type[Planner]] = { @@ -435,10 +434,9 @@ def register_plan_tools( } def _plan_from_dicts(operations: list[dict[str, t.Any]]) -> LazyPlan: - plan = LazyPlan() - for data in operations: - plan.add(operation_from_dict(data)) - return plan + # from_list (not add) so a serialized find-or-create `ensure` probe + # survives the round-trip instead of being silently dropped. + return LazyPlan.from_list(operations) def _planner(name: str) -> Planner: chosen = planners.get(name) From 87da678e8abf25d10b7ead621bbfa5002b2464cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 15:12:44 -0500 Subject: [PATCH 128/223] Ops(test): Make the ensure probe test format-honest why: the fake engine returned all three ids for any display-message, so the ensure test asserted a pane binding while probing only #{session_id} -- it passed on ids the probe never requested and would not catch a real probe/capture format mismatch. what: - Make _FindEngine format-aware (return only the ids the probe requests) - Probe the full capture format in test_ensure_probes_then_creates - Add test_ensure_probe_must_match_create_capture: a session-only probe binds no pane subref, guarding the format contract --- tests/experimental/ops/test_plan.py | 43 +++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index e311ed2c9..61055c378 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -299,13 +299,22 @@ def __init__(self, *, found: bool) -> None: self.calls: list[tuple[str, ...]] = [] def run(self, request: CommandRequest) -> CommandResult: - """Answer a display-message probe, or a new-session create.""" + """Answer a display-message probe, or a new-session create. + + The probe is *format-aware*: it returns only the ids the probe's format + actually requests, so a probe that omits ``#{pane_id}`` yields no pane id + -- mirroring real tmux, so a test cannot pass on ids the probe never asked + for. + """ self.calls.append(request.args) cmd = ("tmux", *request.args) if request.args[0] == "display-message": - if self.found: - return CommandResult(cmd=cmd, stdout=("$9 @9 %9",), returncode=0) - return CommandResult(cmd=cmd, stderr=("no session",), returncode=1) + if not self.found: + return CommandResult(cmd=cmd, stderr=("no session",), returncode=1) + fmt = request.args[-1] # the -p value + ids = {"session_id": "$9", "window_id": "@9", "pane_id": "%9"} + text = " ".join(v for key, v in ids.items() if f"#{{{key}}}" in fmt) + return CommandResult(cmd=cmd, stdout=(text,), returncode=0) return CommandResult(cmd=cmd, stdout=("$1 @1 %1",), returncode=0) def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: @@ -333,9 +342,14 @@ def test_ensure_probes_then_creates_only_if_absent(case: _EnsureCase) -> None: """An ensured create binds a found object's ids, or creates when absent.""" plan = LazyPlan() slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + # The probe renders the SAME capture format the create captures, so a found + # session binds the same self/window/pane subrefs a created one would. plan.ensure( slot.slot, - DisplayMessage(target=NameRef("dev"), message="#{session_id}"), + DisplayMessage( + target=NameRef("dev"), + message="#{session_id} #{window_id} #{pane_id}", + ), ) engine = _FindEngine(found=case.found) result = plan.execute(engine) @@ -347,6 +361,25 @@ def test_ensure_probes_then_creates_only_if_absent(case: _EnsureCase) -> None: assert len(creates) == case.creates # created only when the probe found nothing +def test_ensure_probe_must_match_create_capture() -> None: + """A probe that omits the pane id binds no pane subref (the format contract). + + This guards the ensure() footgun: the probe must render the create's capture + format. A session-only probe finds the session but yields no pane id, so a + downstream ``.pane`` forward-ref would fail closed rather than mis-bind. + """ + plan = LazyPlan() + slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.ensure( + slot.slot, DisplayMessage(target=NameRef("dev"), message="#{session_id}") + ) + + result = plan.execute(_FindEngine(found=True)) + + assert result.bindings[0] == "$9" # the session bound + assert (0, "pane") not in result.bindings # but no pane id -- probe omitted it + + def test_ensure_survives_serialization_round_trip() -> None: """to_list/from_list carry an ensured op's probe, so the conditional persists.""" plan = LazyPlan() From 4f9a8e9dbf4e069827e69cf4cf92f8c742e54e4c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 18:39:54 -0500 Subject: [PATCH 129/223] Workspace(docs): on_event and astream caveats why: The engine-ops streaming surface had two doc gaps a reader could trip on: abuild's on_event docstring invited a slow async sink (a fastmcp Context push) that would head-of-line-block the fold, and astream reads like a drop-in build though it skips host steps and can race send-keys against an unready shell. what: - runner/sets abuild(on_event=): note it is awaited inline on the dispatch coroutine (keep it fast and non-reentrant); a slow sink owns its own buffer and drains independently (the MCP _EventRing pattern); the Awaitable[None] type cannot enforce this - plan.astream: it observes dispatch only and skips host steps (sleep/before_script/wait_pane); use Workspace.abuild for a full build - comment the on_step closure so a future buffered mode uses bounded-queue backpressure, never drop -- BuildEvents carry unique tmux ids that must arrive exactly once --- src/libtmux/experimental/ops/plan.py | 7 +++++++ src/libtmux/experimental/workspace/runner.py | 13 ++++++++++--- src/libtmux/experimental/workspace/sets.py | 6 +++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index f7bd21b6d..be4ffb443 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -551,6 +551,13 @@ async def astream( loop is never blocked between dispatches. Run one ``astream`` per engine at a time (the engine's write order is shared). + Like :meth:`aexecute`, this observes plan *dispatch* only: it does not + run a workspace's host steps (``sleep`` / ``before_script`` / + ``wait_pane``, which the runner threads through ``on_step``), so + ``send-keys`` can reach a shell the plan never waited for. For a full + build that runs those, use + :meth:`~libtmux.experimental.workspace.ir.Workspace.abuild`. + Examples -------- >>> import asyncio diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index 2a73d1222..5bd9e8b32 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -202,9 +202,13 @@ async def abuild_workspace( ) -> PlanResult: """Compile and execute *ws* asynchronously over *engine* (same resolution). - *on_event* is awaited for each build event, so an async observer can stream - the structural progress (e.g. through a fastmcp Context). Folds by default; - see :func:`build_workspace` for the *planner* knob. + *on_event* is awaited inline on the coroutine that runs the interleaved host + steps and drives the next dispatch, so it must return promptly and must not + re-enter *engine* (``Awaitable[None]`` cannot enforce this). A slow network + or render sink should own its own buffer and drain independently -- e.g. the + ``register_events`` + ``_EventRing``-over-``engine.subscribe()`` pattern in + ``libtmux.experimental.mcp.events``. Folds by default; see + :func:`build_workspace` for the *planner* knob. """ if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) @@ -213,6 +217,9 @@ async def abuild_workspace( for step in compiled.pre: await _run_host_async(step, engine, {}, version) + # on_event is awaited inline (keep it fast/non-reentrant); a future buffered + # mode must back-pressure a bounded queue, never drop -- every BuildEvent + # carries a unique tmux id that must arrive exactly once. async def on_step(report: StepReport) -> None: for index, result in zip(report.step.indices, report.results, strict=True): if on_event is not None: diff --git a/src/libtmux/experimental/workspace/sets.py b/src/libtmux/experimental/workspace/sets.py index 157ab2971..26dfddadb 100644 --- a/src/libtmux/experimental/workspace/sets.py +++ b/src/libtmux/experimental/workspace/sets.py @@ -402,7 +402,11 @@ async def abuild_workspaces( on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, planner: Planner | None = None, ) -> WorkspaceSetResult: - """Compile and execute multiple workspaces asynchronously over *engine*.""" + """Compile and execute multiple workspaces asynchronously over *engine*. + + *on_event* has the same inline-await contract as :func:`abuild_workspace`: + keep it fast and non-reentrant, or buffer and drain it yourself. + """ rows = _workspace_tuple(workspaces) active, reused = await _split_reused_async(rows, engine, version, preflight) if not active: From 39b1b7b55a200077b879ab16ddb1d64478a32e11 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 22:11:23 -0500 Subject: [PATCH 130/223] Workspace(fix): Stringify option/env values why: A window/session option or environment value typed as an int in YAML (e.g. `main-pane-height: 35`) reached the ;-chain renderer as an int and raised AttributeError in _escape_arg. The IR declares these Mapping[str, str] and tmux wants string args (classic libtmux str()s every arg), so analyze must coerce at ingest. what: - Add _str_map(): stringify the keys and values of an options or environment mapping - Apply it to all 7 ingest sites (session/window/pane options/options_after/global_options/environment) - Add parametrized coercion tests + a live folded-build regression --- .../experimental/workspace/analyzer.py | 27 +++++-- .../contract/test_workspace_analyze.py | 75 +++++++++++++++++++ .../contract/test_workspace_folding.py | 34 ++++++++- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 45d11f4aa..3eb9d4630 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -49,9 +49,9 @@ def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: name=data["session_name"], dimensions=_dimensions(data.get("dimensions")), start_directory=data.get("start_directory"), - environment=dict(data.get("environment", {}) or {}), - options=dict(data.get("options", {}) or {}), - global_options=dict(data.get("global_options", {}) or {}), + environment=_str_map(data.get("environment")), + options=_str_map(data.get("options")), + global_options=_str_map(data.get("global_options")), windows=windows, before_script=data.get("before_script"), on_exists=data.get("on_exists", "error"), @@ -84,6 +84,19 @@ def _dimensions(value: t.Any) -> tuple[int, int] | None: return (int(width), int(height)) +def _str_map(value: t.Any) -> dict[str, str]: + """Coerce a config options/environment mapping to ``dict[str, str]``. + + YAML types ``main-pane-height: 35`` as an int, but tmux option and + environment values are strings on the command line (libtmux's ``cmd`` + str()s every arg). Stringifying at ingest keeps the IR's declared + ``Mapping[str, str]`` honest, so the folded renderer never meets a non-str. + """ + if not value: + return {} + return {str(key): str(val) for key, val in dict(value).items()} + + def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: """Normalize one window config.""" return Window( @@ -91,9 +104,9 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: layout=raw.get("layout"), start_directory=raw.get("start_directory"), focus=bool(raw.get("focus", False)), - options=dict(raw.get("options", {}) or {}), - options_after=dict(raw.get("options_after", {}) or {}), - environment=dict(raw.get("environment", {}) or {}), + options=_str_map(raw.get("options")), + options_after=_str_map(raw.get("options_after")), + environment=_str_map(raw.get("environment")), window_shell=raw.get("window_shell"), window_index=raw.get("window_index"), panes=[_pane(p) for p in raw.get("panes", []) or []], @@ -156,7 +169,7 @@ def _pane(raw: t.Any) -> Pane: suppress_history=bool(raw.get("suppress_history", True)), sleep_before=raw.get("sleep_before"), sleep_after=raw.get("sleep_after"), - environment=dict(raw.get("environment", {}) or {}), + environment=_str_map(raw.get("environment")), shell=raw.get("shell"), ) msg = f"unsupported pane config: {raw!r}" diff --git a/tests/experimental/contract/test_workspace_analyze.py b/tests/experimental/contract/test_workspace_analyze.py index 72b990d6a..51ea616e7 100644 --- a/tests/experimental/contract/test_workspace_analyze.py +++ b/tests/experimental/contract/test_workspace_analyze.py @@ -59,3 +59,78 @@ def test_supported_shell_command_items_normalize(case: OkCase) -> None: """Valid items normalize; a None mixed with commands is dropped (tmuxp parity).""" ws = analyze(_config(case.shell_command)) assert ws.windows[0].panes[0].run == case.expected + + +class CoerceCase(t.NamedTuple): + """A config options/environment location analyze must stringify. + + YAML types ``main-pane-height: 35`` as an int, but the IR declares these + ``Mapping[str, str]`` and tmux wants string args -- so analyze must coerce. + """ + + test_id: str + config: dict[str, t.Any] + read: t.Callable[..., t.Any] + + +COERCE_CASES = ( + CoerceCase( + "session_options", + {"session_name": "s", "options": {"base-index": 35}, "windows": []}, + lambda ws: ws.options["base-index"], + ), + CoerceCase( + "session_global_options", + {"session_name": "s", "global_options": {"history-limit": 35}, "windows": []}, + lambda ws: ws.global_options["history-limit"], + ), + CoerceCase( + "session_environment", + {"session_name": "s", "environment": {"PORT": 35}, "windows": []}, + lambda ws: ws.environment["PORT"], + ), + CoerceCase( + "window_options", + { + "session_name": "s", + "windows": [{"options": {"main-pane-height": 35}, "panes": ["echo a"]}], + }, + lambda ws: ws.windows[0].options["main-pane-height"], + ), + CoerceCase( + "window_options_after", + { + "session_name": "s", + "windows": [ + {"options_after": {"main-pane-height": 35}, "panes": ["echo a"]}, + ], + }, + lambda ws: ws.windows[0].options_after["main-pane-height"], + ), + CoerceCase( + "window_environment", + { + "session_name": "s", + "windows": [{"environment": {"PORT": 35}, "panes": ["echo a"]}], + }, + lambda ws: ws.windows[0].environment["PORT"], + ), + CoerceCase( + "pane_environment", + { + "session_name": "s", + "windows": [ + {"panes": [{"shell_command": ["echo a"], "environment": {"PORT": 35}}]}, + ], + }, + lambda ws: ws.windows[0].panes[0].environment["PORT"], + ), +) + + +@pytest.mark.parametrize("case", COERCE_CASES, ids=[c.test_id for c in COERCE_CASES]) +def test_analyze_stringifies_option_and_env_values(case: CoerceCase) -> None: + """Non-str option/environment values coerce to str at every ingest site.""" + value = case.read(analyze(case.config)) + assert value == "35" + assert isinstance(value, str) diff --git a/tests/experimental/contract/test_workspace_folding.py b/tests/experimental/contract/test_workspace_folding.py index 78ca64b47..3f69ec4c3 100644 --- a/tests/experimental/contract/test_workspace_folding.py +++ b/tests/experimental/contract/test_workspace_folding.py @@ -16,7 +16,7 @@ from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import SequentialPlanner -from libtmux.experimental.workspace import Command, Pane, Window, Workspace +from libtmux.experimental.workspace import Command, Pane, Window, Workspace, analyze if t.TYPE_CHECKING: from collections.abc import Sequence @@ -146,3 +146,35 @@ def test_build_folds_live_subprocess(session: Session) -> None: # the build folded: at least one ; chain, fewer dispatches than operations assert any(";" in argv for argv in engine.calls) assert len(engine.calls) < len(spec.compile().operations) + + +def test_build_int_window_option_folds(session: Session) -> None: + """A non-str option value (YAML int) folds and builds without a render crash. + + Regression: ``main-pane-height: 35`` reached the ``;``-chain renderer as an + int, which raised ``AttributeError`` in ``_escape_arg``; analyze now + stringifies option values so the fold sees only strings. + """ + server = session.server + engine = _RecordingEngine(SubprocessEngine.for_server(server)) + spec = analyze( + { + "session_name": "int-opt", + "start_directory": "/tmp", + "windows": [ + { + "window_name": "main", + "layout": "main-horizontal", + "options": {"main-pane-height": 35}, + "panes": ["echo a", "echo b", "echo c"], + }, + ], + }, + ) + + result = spec.build(engine) + + assert result.ok + built = server.sessions.get(session_name="int-opt") + assert built is not None + assert len(built.windows[0].panes) == 3 From f8dad206df4d66439b86c0ad0e66b942a3d8fa2f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:36:05 -0500 Subject: [PATCH 131/223] Ops(fix): Skip marked fold for detached creators why: NewPane defaults to detached (-d), which does not focus the new pane. The {marked} fold's untargeted `select-pane -m` then marks the old active pane, so a NewPane >> SendKeys(slot) plan under the default MarkedPlanner sent keys into the wrong pane. SplitWindow is unaffected because it focuses its new pane. what: - _marked_decorates skips creators with detach=True; they dispatch alone so their decorates bind the captured pane id via the slot - Add a parametrized regression: split and focused NewPane still mark, detached NewPane does not --- src/libtmux/experimental/ops/planner.py | 5 ++++ tests/experimental/ops/test_planner.py | 38 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py index 393f1f8a9..f6b57da12 100644 --- a/src/libtmux/experimental/ops/planner.py +++ b/src/libtmux/experimental/ops/planner.py @@ -214,6 +214,11 @@ def _marked_decorates( creator = operations[index] if creator.effects.creates != "pane" or creator.chainable: return () + # A detached creator (e.g. NewPane ``-d``) does not focus its new pane, so + # the {marked} fold's untargeted ``select-pane -m`` would mark the wrong + # pane; fall back to lone dispatch (decorates bind the captured id via slot). + if getattr(creator, "detach", False): + return () decorates: list[int] = [] cursor = index + 1 while cursor < len(operations): diff --git a/tests/experimental/ops/test_planner.py b/tests/experimental/ops/test_planner.py index cc6261e69..1db01252d 100644 --- a/tests/experimental/ops/test_planner.py +++ b/tests/experimental/ops/test_planner.py @@ -15,6 +15,7 @@ FoldingPlanner, LazyPlan, MarkedPlanner, + NewPane, PlanStep, SendKeys, SequentialPlanner, @@ -128,6 +129,43 @@ def test_marked_falls_back_without_pattern() -> None: assert len(engine.calls) == 1 # folded as a plain ; chain +class _MarkedFoldCase(t.NamedTuple): + """A pane creator and whether its decorates should {marked}-fold.""" + + test_id: str + creator: Operation[t.Any] + marked: bool + + +_MARKED_FOLD_CASES = ( + _MarkedFoldCase("split_focuses_new_pane", SplitWindow(target=WindowId("@1")), True), + _MarkedFoldCase( + "detached_new_pane_falls_back", + NewPane(target=PaneId("%1"), width=80, height=15), + False, + ), + _MarkedFoldCase( + "focused_new_pane_marks", + NewPane(target=PaneId("%1"), width=80, height=15, detach=False), + True, + ), +) + + +@pytest.mark.parametrize( + "case", + _MARKED_FOLD_CASES, + ids=[c.test_id for c in _MARKED_FOLD_CASES], +) +def test_marked_fold_skips_detached_creator(case: _MarkedFoldCase) -> None: + """A detached creator's new pane isn't focused, so {marked} can't target it.""" + plan = LazyPlan() + pane = plan.add(case.creator) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + steps = MarkedPlanner().plan(plan.operations) + assert any(step.marked for step in steps) is case.marked + + def _split_decorate_plan() -> list[Operation[t.Any]]: """Return the {marked}-foldable shape: split @1, then two pane decorates.""" return [ From 6e5014fe667c6b7f0f96040e9f69e7ff0c21c185 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:37:44 -0500 Subject: [PATCH 132/223] Engines(fix): End subscribe() after engine death why: subscribe() gated only on _closing, but a dead reader sets _dead, not _closing. A subscribe() after the engine died -- e.g. the output monitor or pull ring reconnecting once tmux closed stdout -- registered a fresh queue and blocked forever on queue.get(), the exact hang the close-subscribers work removed for the aclose() path. what: - Gate subscribe() on `_closing or _dead is not None` - Add a regression mirroring the after-close test but marking dead first --- .../engines/async_control_mode.py | 6 +++--- .../test_async_control_mode_sentinel.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 277b41792..059797f51 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -348,10 +348,10 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: its queue is unregistered on exit. When the engine dies or closes, ``_STREAM_END`` is broadcast to every subscriber queue so the ``async for`` ends cleanly instead of hanging on ``queue.get()``. A subscribe() - after :meth:`aclose` yields nothing (the ``_closing`` gate), since no - broadcast would ever reach its fresh queue. + after :meth:`aclose` or after the engine has died yields nothing, since + no broadcast would ever reach its fresh queue. """ - if self._closing: + if self._closing or self._dead is not None: return queue: asyncio.Queue[t.Any] = asyncio.Queue( maxsize=self._event_queue_size, diff --git a/tests/experimental/engines/test_async_control_mode_sentinel.py b/tests/experimental/engines/test_async_control_mode_sentinel.py index f3bdf2029..d3e2ecdf4 100644 --- a/tests/experimental/engines/test_async_control_mode_sentinel.py +++ b/tests/experimental/engines/test_async_control_mode_sentinel.py @@ -90,3 +90,24 @@ async def drain() -> list[ControlNotification]: return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang assert asyncio.run(main()) == [] + + +def test_subscribe_ends_immediately_after_death() -> None: + """A subscribe() after the engine died must end at once, not hang. + + _mark_dead() broadcasts the sentinel and clears the subscriber set but does + not flip ``_closing``; a queue registered afterwards would never receive a + sentinel. The ``_dead`` gate makes subscribe() yield nothing and return. + """ + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine() + engine._started = True # pretend a connection was established + engine._mark_dead(ControlModeError("tmux -C closed stdout")) + + async def drain() -> list[ControlNotification]: + return [note async for note in engine.subscribe()] + + return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang + + assert asyncio.run(main()) == [] From 0488b35bb1cec67eabfa2e3b8347cf17d4faf6f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:39:18 -0500 Subject: [PATCH 133/223] Query(refactor): Namespace dataclasses.replace why: AGENTS.md's namespace-import rule exempts only dataclass/field from the dataclasses module; replace is a plain function and should be called as dataclasses.replace, matching the sibling snapshots.py. what: - import dataclasses; use dataclasses.replace in filter/order_by/limit --- src/libtmux/experimental/query.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index 0ff2ec57d..0a4ae575a 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -27,8 +27,9 @@ from __future__ import annotations +import dataclasses import typing as t -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from libtmux._internal.query_list import QueryList from libtmux.experimental.engines.base import TmuxEngine @@ -88,15 +89,15 @@ class PaneQuery: def filter(self, **lookups: t.Any) -> PaneQuery: """Narrow by QueryList lookups (e.g. ``active=True``, ``pane_index=0``).""" - return replace(self, lookups={**self.lookups, **lookups}) + return dataclasses.replace(self, lookups={**self.lookups, **lookups}) def order_by(self, field_name: str) -> PaneQuery: """Sort the results by a snapshot attribute (missing values last).""" - return replace(self, order=field_name) + return dataclasses.replace(self, order=field_name) def limit(self, count: int) -> PaneQuery: """Keep only the first *count* results.""" - return replace(self, limit_count=count) + return dataclasses.replace(self, limit_count=count) def all(self, source: PaneSource) -> tuple[PaneSnapshot, ...]: """Resolve the query against *source* and return the matched snapshots.""" From acddbffcffadcf3c66c7652795fa2972988af3f0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:42:08 -0500 Subject: [PATCH 134/223] docs(experimental): Drop prototype lineage why: AGENTS.md's shipped-vs-branch rule keeps never-shipped, branch-internal lineage out of docstrings; the "chainable-commands" and "libtmux-protocol-engines" prototypes are sibling branches the reader never experienced. Describe only current state. what: - Trim prototype references in ops/plan.py, ops/_chain.py, engines/base.py, engines/registry.py --- src/libtmux/experimental/engines/base.py | 3 +-- src/libtmux/experimental/engines/registry.py | 3 +-- src/libtmux/experimental/ops/_chain.py | 5 ++--- src/libtmux/experimental/ops/plan.py | 4 ++-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index f5c6bfd49..7393ed7ce 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -1,7 +1,6 @@ """Core engine abstractions: requests, results, and the engine protocols. -Adapted from the ``libtmux-protocol-engines`` prototype. A -:class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a +A :class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a :class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and :class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with the right methods is an engine -- including a live :class:`libtmux.Server` for diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py index 2809eb356..fb9315ea1 100644 --- a/src/libtmux/experimental/engines/registry.py +++ b/src/libtmux/experimental/engines/registry.py @@ -2,8 +2,7 @@ Lets engines be created by name (or :class:`~.base.EngineSpec`) so downstream code and the contract suite can select a transport without importing its class. -Fails closed on an unknown name. Adapted from the ``libtmux-protocol-engines`` -prototype. +Fails closed on an unknown name. """ from __future__ import annotations diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 2cb254109..3bf94a389 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -2,9 +2,8 @@ A run of *chainable* operations can render to a single ``tmux a \; b`` invocation and dispatch once, instead of one process fork / control-mode command per -operation. This ports the chainable-commands prototype's fold onto the typed-op -model: only operations whose ``chainable`` class var is ``True`` (no captured -output, no created object) fold; the rest dispatch alone. +operation. Only operations whose ``chainable`` class var is ``True`` (no +captured output, no created object) fold; the rest dispatch alone. tmux runs a ``;`` sequence up to the first error and drops the remainder (``cmd-queue.c`` ``cmdq_remove_group``), returning one merged stdout/exit with no diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index be4ffb443..4f66542b5 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -6,8 +6,8 @@ a split is about to create); the plan resolves those references from captured ids at execution time. -Resolution is a sans-I/O generator -- the same yield-operation / resume-with- -result trampoline the chainable-commands prototype uses. The sync +Resolution is a sans-I/O generator -- a yield-operation / resume-with-result +trampoline. The sync :meth:`LazyPlan.execute` and async :meth:`LazyPlan.aexecute` drivers differ only in ``run(...)`` versus ``await arun(...)``; the resolution logic is written once. """ From 802f4d74e7e89861f1af0445f27d65ff78836a87 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:46:12 -0500 Subject: [PATCH 135/223] Workspace(fix): Honor window_shell on window 0 why: window_shell (a window's custom first-pane shell) rode new-window for windows 2..N, but window 0 is created by new-session, which had no shell parameter -- so the first window's first pane silently booted the default shell instead of the configured one. what: - Add window_shell to NewSession (a trailing shell-command, mirroring how NewWindow appends it for later windows) - compiler passes ws.windows[0].window_shell to NewSession - Test that window 0's window_shell rides new-session --- src/libtmux/experimental/ops/_ops/new_session.py | 7 +++++++ src/libtmux/experimental/workspace/compiler.py | 1 + .../test_async_control_engine_workspace_builder.py | 11 +++++++++++ 3 files changed, 19 insertions(+) diff --git a/src/libtmux/experimental/ops/_ops/new_session.py b/src/libtmux/experimental/ops/_ops/new_session.py index b92759c24..1e48ba176 100644 --- a/src/libtmux/experimental/ops/_ops/new_session.py +++ b/src/libtmux/experimental/ops/_ops/new_session.py @@ -35,6 +35,8 @@ class NewSession(Operation[CreateResult]): ('new-session', '-d', '-s', 'work', '-P', '-F', '#{session_id}') >>> NewSession(session_name="work", capture_panes=True).render()[-1] '#{session_id} #{window_id} #{pane_id}' + >>> NewSession(session_name="work", window_shell="fish").render()[-1] + 'fish' >>> NewSession().build_result(returncode=0, stdout=("$2",)).new_id '$2' >>> r = NewSession(capture_panes=True).build_result( @@ -60,6 +62,7 @@ class NewSession(Operation[CreateResult]): height: int | None = None capture: bool = True capture_panes: bool = False + window_shell: str | None = None def args(self, *, version: str | None = None) -> tuple[str, ...]: """Render ``new-session`` flags (always detached for headless use).""" @@ -81,6 +84,10 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: else "#{session_id}" ) out.extend(("-P", "-F", fmt)) + if self.window_shell is not None: + # A trailing shell-command runs in the first pane instead of the + # default shell (as NewWindow does for windows 2..N). + out.append(self.window_shell) return tuple(out) def _make_result( diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 13fe27b1f..4b9786eba 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -399,6 +399,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: width=width, height=height, environment=_creator_environment(ws.windows[0]) or None, + window_shell=ws.windows[0].window_shell, capture_panes=True, ), ) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 3a427c48c..db1364213 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -646,6 +646,17 @@ def test_compile_threads_window_and_pane_shell() -> None: assert split.shell == "zsh" # pane.shell wins over window_shell +def test_compile_first_window_shell_rides_new_session() -> None: + """window_shell on window 0 rides new-session (not silently dropped).""" + ws = Workspace( + name="ws-shell0", + windows=[Window("a", window_shell="fish", panes=[Pane(run="x")])], + ) + ops = compile_full(ws).plan.operations + new_session = next(op for op in ops if isinstance(op, NewSession)) + assert new_session.window_shell == "fish" + + def test_compile_emits_options_after_following_layout() -> None: """options_after compile to set-window-option *after* the layout op.""" ws = Workspace( From 0a131fc0ef70727546eb5150db0b3c30c54a1d1d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:49:47 -0500 Subject: [PATCH 136/223] Workspace(fix): wait_pane uses effective shell why: The wait_pane readiness guard skipped the wait when a first pane set `shell=`, but the first pane's own shell is never applied (only window_shell reaches its creator). So the pane launched the default shell and send-keys raced the prompt -- the exact race wait_pane exists to prevent. what: - _emit_pane_commands takes the shell the creator actually applies instead of re-deriving pane.shell|window_shell: first pane -> window_shell, split -> pane.shell|window_shell, float -> pane.shell - Parametrized regression: a first-pane pane.shell still waits, a first-pane window_shell skips --- .../experimental/workspace/compiler.py | 16 +++++-- ..._async_control_engine_workspace_builder.py | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 4b9786eba..4925d82a2 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -165,19 +165,21 @@ def _emit_pane_commands( host_after: dict[int, list[HostStep]], pre: list[HostStep], ws: Workspace, - window: Window, pane: Pane, target: SlotRef, + effective_shell: str | None, ) -> None: """Emit a pane's command sends with their host-side sleep / wait scheduling. Shared by tiled panes and floating-pane overlays so both honor ``wait_pane``, ``suppress_history``, and the per-pane / per-command sleeps identically. + *effective_shell* is the shell actually applied to this pane by its creator + (``None`` means the default shell), which gates the readiness wait. """ commands = pane.commands if not commands: return - if ws.wait_pane and (pane.shell or window.window_shell) is None: + if ws.wait_pane and effective_shell is None: # Wait for the pane's shell prompt before sending keys (anti-race); a pane # launching a custom shell/command does not get this wait, mirroring # tmuxp's `if pane_shell is None`. @@ -254,7 +256,7 @@ def _emit_float( shell_command=fp.pane.shell, ), ) - _emit_pane_commands(plan, host_after, pre, ws, host_window, fp.pane, float_ref) + _emit_pane_commands(plan, host_after, pre, ws, fp.pane, float_ref, fp.pane.shell) if fp.pane.focus: plan.add(SelectPane(target=float_ref)) @@ -322,8 +324,12 @@ def _emit_window( focus_targets: list[SlotRef] = [] for pane_index, pane in enumerate(panes): if pane_index == 0: + # The first pane rides the creator, which applies window_shell (not + # the pane's own shell); reflect that in the readiness gate. target: SlotRef = first_pane_ref + effective_shell = window.window_shell else: + effective_shell = pane.shell or window.window_shell target = plan.add( SplitWindow( target=prev, @@ -333,10 +339,10 @@ def _emit_window( or ws.start_directory ), environment={**window.environment, **pane.environment} or None, - shell=pane.shell or window.window_shell, + shell=effective_shell, ), ) - _emit_pane_commands(plan, host_after, pre, ws, window, pane, target) + _emit_pane_commands(plan, host_after, pre, ws, pane, target, effective_shell) if pane.focus: focus_targets.append(target) prev = target diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index db1364213..a5a0e4c4f 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -776,6 +776,54 @@ def test_compile_emits_wait_pane_when_enabled() -> None: ) +class _WaitFirstPaneCase(t.NamedTuple): + """A first-pane shell config and whether wait_pane should still wait.""" + + test_id: str + window_shell: str | None + pane_shell: str | None + expect_wait: bool + + +_WAIT_FIRST_PANE_CASES = ( + _WaitFirstPaneCase("plain_waits", None, None, True), + # Regression: the first pane's own shell is never applied, so the wait + # must still fire (the pane runs the default shell and draws a prompt). + _WaitFirstPaneCase("pane_shell_still_waits", None, "fish", True), + # window_shell IS applied to the first pane by its creator, so skip. + _WaitFirstPaneCase("window_shell_skips", "fish", None, False), +) + + +@pytest.mark.parametrize( + "case", + _WAIT_FIRST_PANE_CASES, + ids=[c.test_id for c in _WAIT_FIRST_PANE_CASES], +) +def test_compile_wait_pane_first_pane_effective_shell( + case: _WaitFirstPaneCase, +) -> None: + """wait_pane gates the first pane on the shell its creator actually applies.""" + ws = Workspace( + name="ws-wait-first", + wait_pane=True, + windows=[ + Window( + "w", + window_shell=case.window_shell, + panes=[Pane(run="x", shell=case.pane_shell)], + ), + ], + ) + compiled = compile_full(ws) + waited = any( + step.kind == "wait_pane" + for steps in (*compiled.host_after.values(), compiled.pre) + for step in steps + ) + assert waited is case.expect_wait + + def test_compile_window_index_targets_session_index() -> None: """window_index places a created window at session:N (capture preserved).""" ws = Workspace( From 9a3eab5726f2e3dd539eff7e16e3406f088859cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:51:09 -0500 Subject: [PATCH 137/223] docs(experimental): Add arun/aexecute doctests why: AGENTS.md requires working doctests on all methods; the async twins arun (fluent) and aexecute (plan) had none, unlike their documented siblings run and astream. what: - Add runnable async doctests (asyncio.run over AsyncConcreteEngine) --- src/libtmux/experimental/fluent.py | 12 +++++++++++- src/libtmux/experimental/ops/plan.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index 265426f4c..2fd913bc9 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -281,7 +281,17 @@ async def arun( version: str | None = None, planner: Planner | None = None, ) -> PlanResult: - """Async twin of :meth:`run` (same fold and host steps, ``await``ed).""" + """Async twin of :meth:`run` (same fold and host steps, ``await``ed). + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> p = plan() + >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) + >>> asyncio.run(p.arun(AsyncConcreteEngine())).ok + True + """ async def on_step(report: StepReport) -> None: for action in self._host_after.get(report.step.indices[-1], ()): diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 4f66542b5..a3cc7485e 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -507,6 +507,17 @@ async def aexecute( """Resolve and execute the plan asynchronously (same resolution core). Mirrors :meth:`execute`; *on_step* is awaited per step. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> plan = LazyPlan() + >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) + >>> asyncio.run(plan.aexecute(AsyncConcreteEngine())).ok + True """ version = resolve_engine_version(engine, version) gen = self._drive(version, planner or SequentialPlanner()) From 843b0683feec5d245463a1a3806bacc0b233faf8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:28:41 -0500 Subject: [PATCH 138/223] Facade(fix): Add AsyncWindow.select_layout why: EagerWindow and LazyWindow expose select_layout, but AsyncWindow lacked it -- `await window.select_layout(...)` raised AttributeError, breaking eager/lazy/async parity for the layout op. what: - Add AsyncWindow.select_layout mirroring the eager/lazy method - Assert it in the async facade parity test --- src/libtmux/experimental/facade/window.py | 8 ++++++++ tests/experimental/facade/test_facade_matrix.py | 8 +++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/facade/window.py b/src/libtmux/experimental/facade/window.py index 32e4ed93a..f3a2476f9 100644 --- a/src/libtmux/experimental/facade/window.py +++ b/src/libtmux/experimental/facade/window.py @@ -188,6 +188,14 @@ async def rename(self, name: str) -> Result: version=self.version, ) + async def select_layout(self, layout: str) -> Result: + """Apply a layout to this window.""" + return await arun( + SelectLayout(target=WindowId(self.window_id), layout=layout), + self.engine, + version=self.version, + ) + async def kill(self) -> Result: """Kill this window.""" return await arun( diff --git a/tests/experimental/facade/test_facade_matrix.py b/tests/experimental/facade/test_facade_matrix.py index 0e37df75f..eefab9457 100644 --- a/tests/experimental/facade/test_facade_matrix.py +++ b/tests/experimental/facade/test_facade_matrix.py @@ -57,16 +57,18 @@ def test_lazy_window_records_and_executes() -> None: def test_async_window_and_pane() -> None: """Async facades mirror the eager ones via await.""" - async def main() -> tuple[str, bool]: + async def main() -> tuple[str, bool, bool]: window = AsyncWindow(AsyncConcreteEngine(), "@1") pane = await window.split() assert isinstance(pane, AsyncPane) sent = await pane.send_keys("echo hi", enter=True) - return pane.pane_id, sent.ok + laid_out = await window.select_layout("tiled") # parity with eager/lazy + return pane.pane_id, sent.ok, laid_out.ok - pane_id, ok = asyncio.run(main()) + pane_id, ok, laid_out = asyncio.run(main()) assert pane_id == "%1" assert ok + assert laid_out def test_eager_navigation_live(session: Session) -> None: From cbc0327b8ba91e616174e62f7f34d39700ee4550 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:31:06 -0500 Subject: [PATCH 139/223] Engines(fix): ImsgEngine reports tmux version why: ImsgEngine executes against a live tmux server but did not implement tmux_version(), unlike every other real-server engine. So resolve_engine_version() fell back to "assume latest", and a version-gated op run over imsg against an older server rendered a too-new flag that tmux rejects -- diverging from the subprocess and control-mode engines on the same server. what: - Add ImsgEngine.tmux_version(): memoized tmux -V via the resolved binary, mirroring SubprocessEngine - Test it satisfies SupportsTmuxVersion and reports/memoizes a version --- src/libtmux/experimental/engines/imsg/base.py | 18 ++++++++++++++++++ tests/experimental/engines/test_imsg.py | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 61e03f221..ba433a36e 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -14,6 +14,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandRequest, CommandResult from libtmux.experimental.engines.imsg.exc import ( ImsgProtocolError, @@ -227,6 +228,23 @@ def __init__(self, protocol_version: str | int | None = None) -> None: str(protocol_version) if protocol_version is not None else None ) self._resolved_tmux_bin: str | None = None + self._tmux_version: str | None = None + self._version_probed = False + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Like the other real-server engines, so version-gated ops render for the + true server version instead of degrading to "assume latest". Returns + ``None`` when the binary is missing or its version cannot be parsed. + """ + if not self._version_probed: + self._version_probed = True + try: + self._tmux_version = str(get_version(self._resolve_tmux_bin())) + except exc.LibTmuxException: + self._tmux_version = None + return self._tmux_version def run(self, request: CommandRequest) -> CommandResult: """Execute a tmux command over the server socket.""" diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py index c70e39abe..6dae9e163 100644 --- a/tests/experimental/engines/test_imsg.py +++ b/tests/experimental/engines/test_imsg.py @@ -58,6 +58,17 @@ def test_imsg_registered() -> None: assert type(create_engine("imsg")).__name__ == "ImsgEngine" +def test_imsg_reports_tmux_version() -> None: + """ImsgEngine resolves its tmux version so version-gated ops render right.""" + from libtmux.experimental.engines.base import SupportsTmuxVersion + + engine = ImsgEngine() + assert isinstance(engine, SupportsTmuxVersion) # not "assume latest" + version = engine.tmux_version() + assert version is not None # real tmux is on PATH in the test env + assert version == engine.tmux_version() # memoized + + def test_v8_codec_header_round_trip() -> None: """A v8 frame packs to wire bytes and its header unpacks back (no tmux).""" codec = ProtocolV8Codec() From 03eb76474d9e9326337adf3410680362c2dca92b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:35:17 -0500 Subject: [PATCH 140/223] Mcp(fix): Gate wait_for_output prompts on events why: The run_and_wait / diagnose_failing_pane / interrupt_gracefully prompts steer the agent to call wait_for_output, but that tool is registered only on a streaming control-mode async server. On the sync server (and non-streaming async) the agent was told to call a tool that does not exist -> tool-not-found. what: - register_prompts(events_enabled=): register the wait_for_output prompts only when events streaming is enabled; build_dev_workspace always registers - build_async_server passes its computed events_enabled; the sync build_server leaves it False - Parametrized gate regression + a sync-server integration check --- .../experimental/mcp/fastmcp_adapter.py | 2 +- src/libtmux/experimental/mcp/prompts.py | 25 ++++++----- tests/experimental/mcp/test_prompts.py | 41 +++++++++++++++++-- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index e35d77c9c..f74ff427f 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -765,7 +765,7 @@ def build_async_server( if include_prompts: from libtmux.experimental.mcp.prompts import register_prompts - register_prompts(mcp) + register_prompts(mcp, events_enabled=events_enabled) if include_operations: register_operations( mcp, diff --git a/src/libtmux/experimental/mcp/prompts.py b/src/libtmux/experimental/mcp/prompts.py index 582826ba5..be4f9ccb3 100644 --- a/src/libtmux/experimental/mcp/prompts.py +++ b/src/libtmux/experimental/mcp/prompts.py @@ -3,8 +3,8 @@ Each function is a FastMCP prompt -- a template returning the text an MCP client sends to its model, packaging a few common workflows over the engine-ops vocabulary (``send_input`` / ``wait_for_output`` / ``capture_pane`` / -``create_session`` / ``split_pane``). Pure string builders, engine-agnostic, so -the same set registers on both the sync and async servers. +``create_session`` / ``split_pane``). Pure string builders, engine-agnostic; +:func:`register_prompts` picks which apply to a given server. """ from __future__ import annotations @@ -76,14 +76,19 @@ def interrupt_gracefully(pane_id: str) -> str: how to proceed -- do NOT escalate automatically to C-\ (SIGQUIT) or kill.""" -def register_prompts(mcp: FastMCP) -> None: - """Register the recipe prompts on *mcp*.""" +def register_prompts(mcp: FastMCP, *, events_enabled: bool = False) -> None: + """Register the recipe prompts on *mcp*. + + The prompts that steer an agent to call ``wait_for_output`` are registered + only when *events_enabled* -- that tool exists only on a streaming + control-mode server, so naming it elsewhere would send the agent to a + missing tool. ``build_dev_workspace`` needs no such tool and always + registers. + """ from fastmcp.prompts import Prompt - for fn in ( - run_and_wait, - diagnose_failing_pane, - build_dev_workspace, - interrupt_gracefully, - ): + prompts: list[t.Callable[..., str]] = [build_dev_workspace] + if events_enabled: + prompts += [run_and_wait, diagnose_failing_pane, interrupt_gracefully] + for fn in prompts: mcp.add_prompt(Prompt.from_function(fn, name=fn.__name__)) diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py index 341d8fa84..175737a83 100644 --- a/tests/experimental/mcp/test_prompts.py +++ b/tests/experimental/mcp/test_prompts.py @@ -30,15 +30,50 @@ ) -def test_prompts_registered() -> None: - """The four recipe prompts are registered on the server.""" +def test_sync_server_omits_wait_for_output_prompts() -> None: + """The sync server lacks wait_for_output, so those recipe prompts are gated off.""" server = build_server(ConcreteEngine()) async def main() -> set[str]: async with fastmcp.Client(server) as client: return {prompt.name for prompt in await client.list_prompts()} - assert asyncio.run(main()) >= _NAMES + names = asyncio.run(main()) + assert "build_dev_workspace" in names # needs no wait_for_output + assert names.isdisjoint(_NAMES - {"build_dev_workspace"}) + + +class _PromptGateCase(t.NamedTuple): + """events_enabled and the prompt names that should register.""" + + test_id: str + events_enabled: bool + expected: set[str] + + +_PROMPT_GATE_CASES = ( + _PromptGateCase("events_off", False, {"build_dev_workspace"}), + _PromptGateCase("events_on", True, _NAMES), +) + + +@pytest.mark.parametrize( + "case", + _PROMPT_GATE_CASES, + ids=[c.test_id for c in _PROMPT_GATE_CASES], +) +def test_register_prompts_gates_wait_for_output(case: _PromptGateCase) -> None: + """wait_for_output prompts register only when events (streaming) are enabled.""" + from libtmux.experimental.mcp.prompts import register_prompts + + mcp = fastmcp.FastMCP("test") + register_prompts(mcp, events_enabled=case.events_enabled) + + async def main() -> set[str]: + async with fastmcp.Client(mcp) as client: + return {prompt.name for prompt in await client.list_prompts()} + + assert asyncio.run(main()) == case.expected def test_prompt_bodies_use_engine_ops_vocabulary() -> None: From fb9f8a6b2e3f004ad5c990f93b34d1bc8fbc53c5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:36:30 -0500 Subject: [PATCH 141/223] Mcp(fix): Redact non-str sensitive audit args why: The audit summary redacted a sensitive arg (keys/text/command/...) only when it was a str or dict; a non-conformant client sending e.g. command=["secret"] fell through to the raw-passthrough branch and logged the payload verbatim into the INFO audit record -- contradicting the documented "payload-bearing arguments never reach the log raw" promise. Schema validation rejects such calls, but _summarize_args runs first. what: - Shape-redact any other-typed sensitive value via _redacted_value_shape - Doctest that a list-valued sensitive arg is redacted --- src/libtmux/experimental/mcp/middleware.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libtmux/experimental/mcp/middleware.py b/src/libtmux/experimental/mcp/middleware.py index d7510e03c..4b4f5c2fb 100644 --- a/src/libtmux/experimental/mcp/middleware.py +++ b/src/libtmux/experimental/mcp/middleware.py @@ -524,6 +524,8 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: 3 >>> "bar" in str(redacted) False + >>> "secret" in str(_summarize_args({"command": ["secret"]})) # non-str value + False """ summary: dict[str, t.Any] = {} for key, value in args.items(): @@ -531,6 +533,10 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: summary[key] = _redact_digest(value) elif key in _SENSITIVE_ARG_NAMES and isinstance(value, dict): summary[key] = {k: _redact_digest(str(v)) for k, v in value.items()} + elif key in _SENSITIVE_ARG_NAMES: + # Any other-typed sensitive value (e.g. a list from a non-conformant + # client) is shape-redacted, never passed through to the log raw. + summary[key] = _redacted_value_shape(value) elif key in _NESTED_ARG_LIST_NAMES: if isinstance(value, list): summary[key] = [ From b83a4f8391b42552865821bcb3c6a35944f20f18 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:38:10 -0500 Subject: [PATCH 142/223] docs(experimental): Trim more branch narrative why: AGENTS.md shipped-vs-branch keeps never-shipped, branch-internal narrative out of docstrings; round 1 missed a few. what: - control_mode.py: drop the "chainable-commands runner / libtmux-protocol-engines parser" lineage sentence - concrete.py: drop "preserving the historical behaviour" (an intermediate in-branch state) - new_pane.py: drop the "first operation gated by min_version" ordinal; keep only the current-state VersionUnsupported clause --- src/libtmux/experimental/engines/concrete.py | 6 +++--- src/libtmux/experimental/engines/control_mode.py | 3 +-- src/libtmux/experimental/ops/_ops/new_pane.py | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index 50774e431..78863eda8 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -24,9 +24,9 @@ def _fabricate(fmt: str, counters: dict[str, int]) -> str: """Fabricate one id per ``#{..._id}`` token in *fmt*, in their order. - A single-token format (e.g. ``#{pane_id}``) yields one id, preserving the - historical behaviour; a multi-token capture (e.g. ``new-session -F - '#{session_id} #{window_id} #{pane_id}'``) yields a space-joined id per token. + A single-token format (e.g. ``#{pane_id}``) yields one id; a multi-token + capture (e.g. ``new-session -F '#{session_id} #{window_id} #{pane_id}'``) + yields a space-joined id per token. """ found: list[tuple[int, str, str]] = [] for key, sigil in (("session_id", "$"), ("window_id", "@"), ("pane_id", "%")): diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 31e15611a..2fee21f50 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -5,8 +5,7 @@ :class:`~.base.CommandResult`. Because it returns the same typed result the subprocess engine does, an operation run through control mode is indistinguishable -- at the result level -- from one run through a fork-per-call -subprocess. Adapted from the chainable-commands control runner and the -``libtmux-protocol-engines`` parser. +subprocess. The parser (:class:`ControlModeParser`) is I/O-free: it consumes bytes and emits parsed blocks, so it is unit-testable without spawning tmux. ``run_batch`` writes diff --git a/src/libtmux/experimental/ops/_ops/new_pane.py b/src/libtmux/experimental/ops/_ops/new_pane.py index 7409a29f8..c4398bb37 100644 --- a/src/libtmux/experimental/ops/_ops/new_pane.py +++ b/src/libtmux/experimental/ops/_ops/new_pane.py @@ -32,9 +32,9 @@ class NewPane(Operation[SplitWindowResult]): :class:`~.results.SplitWindowResult`, capturing the new pane id via ``-P -F '#{pane_id}'`` so plans, the facade, and MCP bind it the same way. - This is the first operation gated by :attr:`~.operation.Operation.min_version`: - rendering against a tmux older than 3.7 raises - :exc:`~.exc.VersionUnsupported`. + Rendering against a tmux older than 3.7 raises + :exc:`~.exc.VersionUnsupported` (this op sets + :attr:`~.operation.Operation.min_version`). Parameters ---------- From 96af357ded4a01b2ae585f2a88849829d7ba27ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:38:42 -0500 Subject: [PATCH 143/223] Mcp(docs): Fix build_workspace registration claim why: register_plan_tools' docstring said build_workspace is registered only on the synchronous server, but the async branch registers it too (dispatching to abuild_workspace) -- misleading a maintainer about tool exposure. what: - State it registers on both servers, per-engine dispatch --- src/libtmux/experimental/mcp/fastmcp_adapter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index f74ff427f..e79a01457 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -411,8 +411,8 @@ def register_plan_tools( ``preview_plan`` / ``result_schema`` are pure; ``execute_plan`` runs a serialized plan (via :func:`~..mcp.plan_tools.aexecute_plan` on an async - engine). ``build_workspace`` is registered only on the synchronous server - (the declarative runner is synchronous). + engine). ``build_workspace`` is registered on both servers -- dispatching to + ``abuild_workspace`` on an async engine and ``build_workspace`` on a sync one. """ from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations From fa050a38531fad068d5cbd6488bdbc0ebea99a94 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:57:34 -0500 Subject: [PATCH 144/223] Workspace(fix): Confirm cwd on the first pane why: The session start_directory lands on window 0's first pane, but confirm() read the window's active_pane. When a non-first pane is focused (or a split leaves focus off pane 0) with a different cwd, confirm falsely reported "first pane cwd != declared". what: - Check the first window's first pane (index 0), not active_pane - Live regression: a focused non-first pane at a different cwd still confirms ok --- src/libtmux/experimental/workspace/confirm.py | 6 +++- ..._async_control_engine_workspace_builder.py | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py index 12b958a54..cc92394d9 100644 --- a/src/libtmux/experimental/workspace/confirm.py +++ b/src/libtmux/experimental/workspace/confirm.py @@ -81,7 +81,11 @@ def _cwd_ok() -> bool: fresh = server.sessions.filter(session_id=session_id) if not fresh: return False - pane = next(iter(fresh[0].windows)).active_pane + # start_directory sets the session -c, which lands on window 0's + # FIRST pane -- not necessarily the active one (a split or focused + # non-first pane can carry a different cwd). + panes = list(next(iter(fresh[0].windows)).panes) + pane = panes[0] if panes else None return pane is not None and pane.pane_current_path == want_cwd if not retry_until(_cwd_ok, timeout, raises=False): diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index a5a0e4c4f..2bf4bd8b7 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -193,6 +193,37 @@ def test_workspace_builder_subprocess_live( assert report.ok, report.problems +def test_confirm_start_directory_checks_first_pane_not_active( + session: Session, + tmp_path: Path, +) -> None: + """start_directory is confirmed on window 0's FIRST pane, not the active one. + + Regression: confirm read active_pane, so a focused non-first pane with a + different cwd falsely reported "first pane cwd != declared". + """ + server = session.server + other = tmp_path / "other" + other.mkdir() + spec = Workspace( + name="ws-cwd-first", + start_directory=str(tmp_path), + windows=[ + Window( + "w", + panes=[ + Pane(run="echo a"), # first pane inherits ws.start_directory + Pane(run="echo b", start_directory=str(other), focus=True), + ], + ), + ], + ) + + assert spec.build(SubprocessEngine.for_server(server)).ok + report = confirm(spec, server) + assert report.ok, report.problems # first pane matches despite focus elsewhere + + # --- Robust QA: a rich workspace exercising the full feature surface --- From 075149e9ed3330f5d0fa937c21a97e1b5b7ffa1e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:21:21 -0500 Subject: [PATCH 145/223] Engines(feat[async_control_mode]): Add supervised reconnect why: A tmux restart or socket blip killed the async control-mode engine permanently -- the reader EOF'd, the engine went dead, and subscribers had to be torn down. A supervisor that self-heals keeps the event stream alive across the gap and lets subscribe() rely on _closing alone instead of a sticky _dead gate. what: - Replace the one-shot start()/reader with a supervisor that spawns tmux -C, replays desired subscriptions/attach, runs the reader inline (one at a time), and reconnects with deterministic jittered backoff when the reader returns on EOF. - Add add_subscription()/set_attach_targets() declarative state, replayed on every (re)connect; bump _generation per connect and reset parser/pending/attach before the new proc's bytes flow. - Surface first-connect failure to every start() caller; reset the backoff on a healthy connect. - Gate subscribe() on _closing only: a reconnecting (_dead) engine keeps its subscriber so the post-reconnect reader feeds it. - Cover supervisor reconnect, attach replay, and attach reset; drop the now-obsolete _dead-gate sentinel test. --- .../engines/async_control_mode.py | 434 ++++++++++++++---- .../test_async_control_mode_sentinel.py | 21 - .../test_async_control_mode_supervisor.py | 177 +++++++ tests/experimental/mcp/test_attach_reset.py | 13 + 4 files changed, 547 insertions(+), 98 deletions(-) create mode 100644 tests/experimental/engines/test_async_control_mode_supervisor.py create mode 100644 tests/experimental/mcp/test_attach_reset.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 059797f51..4de396497 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -14,10 +14,18 @@ - Command correlation is a FIFO of futures resolved in block-arrival order. A block that arrives with *no* pending command is **unsolicited** (a hook- triggered command, or the startup ACK) and is skipped, so correlation never - desyncs. The startup ACK is consumed synchronously in :meth:`start` before the - reader launches, closing the startup race. + desyncs. The startup ACK is consumed synchronously in :meth:`_spawn` before + the reader runs, closing the startup race. +- A supervisor owns the process lifecycle. :meth:`start` launches it once; it + spawns ``tmux -C``, replays the desired subscriptions, and runs the reader + inline (one reader at a time). When the reader returns on EOF, the supervisor + resets connection-scoped state -- a fresh parser, failed pending commands, + cleared attach -- bumps the connection generation, and reconnects with a + deterministic jittered backoff, so a tmux restart or socket blip self-heals + instead of freezing the engine. An intentional :meth:`aclose` flags + ``_closing`` first so the close is not mistaken for a crash and retried. - A reader failure or EOF marks the engine *dead* and fails every pending - command, rather than hanging. + command, rather than hanging; the supervisor then reconnects. - Notifications go to a bounded queue; on overflow the oldest is dropped and counted (backpressure), mirroring control mode's own ``%pause`` philosophy. """ @@ -52,6 +60,7 @@ _DEFAULT_TIMEOUT = 30.0 _STARTUP_TIMEOUT = 5.0 _STOP_TIMEOUT = 2.0 + _STREAM_END = object() # broadcast to subscriber queues to end their async for @@ -112,9 +121,10 @@ def _offer( def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: """Put *item* on *queue*, evicting the oldest entry first when it is full. - Like :func:`_offer` but drop-count-free: lands the stream-end sentinel even - on a queue already at ``maxsize``, so a slow consumer that hit backpressure - still gets closed instead of hanging on ``queue.get()``. + Like :func:`_offer` but drop-count-free: used to land the stream-end + sentinel even on a queue already at ``maxsize``, so a slow consumer that hit + backpressure still gets closed instead of hanging on ``queue.get()``. Pulled + out of the broadcast loop so the ``try``/``except`` stays out of it. """ try: queue.put_nowait(item) @@ -128,10 +138,11 @@ def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: def _swallow_future(future: asyncio.Future[t.Any]) -> None: """Retrieve a fire-and-forget future's outcome so it isn't flagged unretrieved. - A reap command is dispatched without an awaiter; its future resolves in the - reader. Calling :meth:`asyncio.Future.exception` marks the result retrieved - so a tmux-side ``%error`` never surfaces as a noisy "exception was never - retrieved" warning. + Subscription-replay commands are dispatched without an awaiter; their futures + resolve in the reader. Calling :meth:`asyncio.Future.exception` marks the + result retrieved so a tmux-side ``%error`` (or a reconnect that fails the + pending command) never surfaces as a noisy "exception was never retrieved" + warning. """ if future.cancelled(): return @@ -176,12 +187,20 @@ def __init__( self._subscribers: set[asyncio.Queue[t.Any]] = set() self._dropped_notifications = 0 self._proc: asyncio.subprocess.Process | None = None - self._reader_task: asyncio.Task[None] | None = None self._start_lock = asyncio.Lock() self._write_lock = asyncio.Lock() self._started = False - self._closing = False self._dead: BaseException | None = None + # Desired (declarative) state, replayed on every (re)connect. + self._desired_subscriptions: list[str] = [] + self._desired_attach: list[str] = [] + self._attached_session: str | None = None + # Supervisor / reconnect bookkeeping. + self._generation = 0 + self._closing = False + self._supervisor_task: asyncio.Task[None] | None = None + self._connected = asyncio.Event() + self._spawn_error: BaseException | None = None def tmux_version(self) -> str | None: """Report the connected server's tmux version (``tmux -V``). @@ -196,33 +215,120 @@ def tmux_version(self) -> str | None: except exc.LibTmuxException: return None + def add_subscription(self, spec: str) -> None: + """Record a desired ``refresh-client -B`` subscription (idempotent). + + The spec is stored in :attr:`_desired_subscriptions` and replayed on + every (re)connect by the supervisor, so a subscription survives a tmux + restart or socket blip. Adding the same spec twice is a no-op. + + Parameters + ---------- + spec : str + A ``refresh-client -B`` subscription spec, e.g. + ``"agentstate:%*:#{@agent_state}"``. + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine._desired_subscriptions + ['agentstate:%*:#{@agent_state}'] + """ + if spec not in self._desired_subscriptions: + self._desired_subscriptions.append(spec) + + def set_attach_targets(self, ids: list[str]) -> None: + """Record the sessions the engine should (re)attach to on reconnect. + + Stores a *copy* of *ids* in :attr:`_desired_attach`. The supervisor + replays these on every (re)connect via :meth:`_replay_attach`, so the + engine stays attached across a tmux restart or socket blip (a control + client attaches to one session at a time, so the last target wins). + + Parameters + ---------- + ids : list[str] + Session ids to attach to (e.g. ``["$0", "$1"]``). + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.set_attach_targets(["$0", "$1"]) + >>> engine._desired_attach + ['$0', '$1'] + """ + self._desired_attach = list(ids) + async def start(self) -> None: - """Spawn ``tmux -C``, consume the startup ACK, and start the reader.""" + """Launch the supervisor (once) and wait for its first connection. + + The supervisor owns the ``tmux -C`` process lifecycle: it spawns the + proc, consumes the startup ACK, replays desired subscriptions, runs the + reader, and reconnects with backoff when the reader returns. This method + is idempotent (the ``_start_lock`` + ``_started`` guard) and never + launches a second supervisor; all callers block until the first + connection is established. + """ async with self._start_lock: - if self._started: - return - tmux_bin = self.tmux_bin or shutil.which("tmux") - if tmux_bin is None: - raise exc.TmuxCommandNotFound - cmd = [tmux_bin, *self.server_args, "-C"] - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + if not self._started: + self._closing = False + self._spawn_error = None + self._connected.clear() + self._supervisor_task = asyncio.create_task( + self._supervisor(), + name="libtmux-async-control-supervisor", ) - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None - self._proc = proc - self._dead = None - await self._consume_startup() - self._reader_task = asyncio.create_task( - self._reader(), - name="libtmux-async-control-reader", + self._started = True + # Block (every caller) until the supervisor's first connect resolves. + if self._supervisor_task is not None: + await self._connected.wait() + if self._spawn_error is not None: + # Keep _spawn_error set here: a *concurrent* start() caller from + # the same failed first connect must also observe it and raise, + # not see a nulled error and return "success" against a dead + # engine. The error is cleared only by the fresh-start reset + # above (the `if not self._started` block) when a NEW attempt + # begins, so every waiter from this failed connect raises + # consistently. + error = self._spawn_error + async with self._start_lock: + self._started = False + self._supervisor_task = None + raise error + + async def _spawn(self) -> None: + """Spawn a fresh ``tmux -C`` process and consume its startup ACK. + + Extracted from :meth:`start` so the supervisor can re-run it on every + reconnect. Sets :attr:`_proc`, then clears :attr:`_dead` only *after* the + startup ACK is consumed (so a command racing the reconnect still hits the + dead-guard). The caller is responsible for resetting the parser *before* + this runs, so the new process's startup bytes are parsed by a fresh parser. + """ + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - await self._reap_own_session() - self._started = True + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + self._proc = proc + # Keep the death-sentinel set while the startup ACK is consumed: across a + # reconnect ``_connected`` stays set, so a concurrent ``run_batch`` would + # otherwise pass the dead-guard, write to the new proc, and have its reply + # DRAINED+DISCARDED by ``_consume_startup`` (its future then times out and + # its stale pending entry desyncs FIFO). Clearing ``_dead`` only after the + # ACK is consumed makes such a racing command hit the dead-guard instead. + await self._consume_startup() + self._dead = None async def _consume_startup(self) -> None: """Read and discard tmux's startup ACK block before commands flow. @@ -254,32 +360,6 @@ async def _consume_startup(self) -> None: if self._parser.blocks(): # startup ACK seen and discarded return - async def _reap_own_session(self) -> None: - """Mark this control client's throwaway session ``destroy-unattached``. - - A bare ``tmux -C`` connect implies ``new-session``, so each connection - spawns a phantom session on the target server. Setting - ``destroy-unattached on`` on the *current* session (no ``-t``/``-g``, so - scoped to exactly that phantom, never global) makes tmux reap it the - moment this client disconnects, so control mode never litters the server - with throwaway sessions. Fire-and-forget: the reader resolves the result - future, which is swallowed. - """ - proc = self._proc - if proc is None or proc.stdin is None: - return - loop = asyncio.get_running_loop() - argv = ("set-option", "destroy-unattached", "on") - async with self._write_lock: - future: asyncio.Future[CommandResult] = loop.create_future() - future.add_done_callback(_swallow_future) - self._pending.append(_PendingCommand(future, argv, command_count(argv))) - try: - proc.stdin.write((render_control_line(argv) + "\n").encode()) - await proc.stdin.drain() - except (BrokenPipeError, OSError): - return - async def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" return (await self.run_batch([request]))[0] @@ -344,14 +424,19 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: Each subscriber gets its own queue, so concurrent subscribers (the event push tool, the pull ring, the output monitor) each see *every* notification rather than competing for one shared stream. The iterator - runs until the engine dies or is closed, or the caller stops iterating; - its queue is unregistered on exit. When the engine dies or closes, - ``_STREAM_END`` is broadcast to every subscriber queue so the ``async - for`` ends cleanly instead of hanging on ``queue.get()``. A subscribe() - after :meth:`aclose` or after the engine has died yields nothing, since - no broadcast would ever reach its fresh queue. + runs until the engine is closed or the caller stops iterating; its queue + is unregistered on exit. When the engine dies, ``_STREAM_END`` is + broadcast to every subscriber queue so the ``async for`` ends cleanly + instead of hanging on ``queue.get()``. + + A subscribe() *after* :meth:`aclose` (which set :attr:`_closing`, + broadcast the stream-end sentinel, and cleared :attr:`_subscribers`) + would register a fresh queue no broadcast will ever touch, hanging the + consumer forever. So a permanently-closing engine yields nothing and + ends at once. A merely :attr:`_dead` (reconnecting) engine keeps the + subscriber, so the post-reconnect reader feeds it. """ - if self._closing or self._dead is not None: + if self._closing: return queue: asyncio.Queue[t.Any] = asyncio.Queue( maxsize=self._event_queue_size, @@ -372,21 +457,26 @@ def dropped_notifications(self) -> int: return self._dropped_notifications async def aclose(self) -> None: - """Tear down: flag closing, cancel the reader, end subscribers, kill proc. + """Tear down: flag closing, cancel the supervisor, fail pending, kill proc. - Setting ``_closing`` first makes a subscribe() racing the close end at - once instead of registering a queue no broadcast will reach. + Setting :attr:`_closing` *first* distinguishes an intentional close from a + crash, so cancelling the supervisor (and the reader it owns inline) ends + the loop instead of triggering a reconnect. """ if not self._started: return self._closing = True self._started = False - reader = self._reader_task - self._reader_task = None - if reader is not None: - reader.cancel() + supervisor = self._supervisor_task + self._supervisor_task = None + if supervisor is not None: + supervisor.cancel() with contextlib.suppress(asyncio.CancelledError): - await reader + await supervisor + # Release any start() still blocked on the first connection. The + # supervisor's finally covers a normal exit, but a task cancelled before + # it ever runs never reaches that finally, so set it here too. + self._connected.set() self._broadcast_stream_end() self._fail_pending(ControlModeError("control-mode engine closed")) proc = self._proc @@ -415,6 +505,196 @@ async def __aexit__( """Close the engine on context exit.""" await self.aclose() + async def _supervisor(self) -> None: + """Own the proc lifecycle: connect, replay desired state, read, reconnect. + + One supervisor runs at a time (launched once by :meth:`start`). Each + iteration resets connection-scoped state *before* the new process's bytes + flow -- a fresh :class:`~.control_mode.ControlModeParser`, failed pending + commands, cleared attach -- then spawns ``tmux -C``, bumps + :attr:`_generation`, replays subscriptions, and runs the reader inline so + there is never more than one reader. When the reader returns on EOF (and + the engine is not :attr:`_closing`), it backs off with deterministic + jitter and reconnects. An intentional :meth:`aclose` cancels this task, + which propagates into the inline reader. + """ + attempt = 0 + connected_once = False + try: + while not self._closing: + # Reset connection-scoped state BEFORE the new proc's bytes flow. + # Reconnect is the only place permitted to reset the parser and + # fail pending, keeping FIFO correlation aligned across the gap. + self._parser = ControlModeParser() + self._fail_pending(ControlModeError("control-mode reconnecting")) + self._reset_attach() + try: + await self._spawn() + except asyncio.CancelledError: + raise + except BaseException as error: + if not connected_once: + # First connect failed (e.g. missing binary): surface it + # to start() and stop -- a permanent error should not spin. + self._spawn_error = error + return + # A transient spawn failure mid-life: back off and retry. + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + continue + # The spawn succeeded and its startup ACK was consumed, so this + # connection is healthy: reset the backoff. A reconnect after a + # long healthy session then starts fast instead of waiting near + # the cap -- only *consecutive connect failures* (the except path + # above) escalate. + attempt = 0 + self._generation += 1 + connected_once = True + await self._reap_own_session() + await self._replay_subscriptions() + await self._replay_attach() + self._connected.set() # first connect done: unblock start() + # The reader runs inline (one reader at a time). On EOF it returns + # and we reconnect; on cancellation (aclose) it propagates out. + await self._reader() + if self._closing: + return + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + finally: + # Always release start() waiters -- even on cancel/return before the + # first connect -- so an aclose() racing a start() can never leave a + # waiter blocked on _connected.wait() forever. + self._connected.set() + + async def _reap_own_session(self) -> None: + """Mark this control client's throwaway session ``destroy-unattached``. + + A bare ``tmux -C`` connect implies ``new-session``, so each connection + spawns a phantom session on the target server. Right after connect -- while + the control client is still attached to that just-created session, before + :meth:`_replay_attach` moves it -- this sets ``destroy-unattached on`` on + the *current* session (the phantom; no ``-t`` and no ``-g``, so it is scoped + to exactly that session, never global). tmux then reaps the phantom the + moment the client attaches elsewhere or disconnects, so control-mode never + litters the server with throwaway sessions and a reconnect storm cannot pile + them up. Fire-and-forget, like :meth:`_replay_subscriptions` -- the result + block is swallowed since the reader has not started yet. + """ + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + argv = ("set-option", "destroy-unattached", "on") + async with self._write_lock: + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + try: + proc.stdin.write((render_control_line(argv) + "\n").encode()) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before the option landed; the reader will EOF and + # the supervisor reconnects (the next connect re-marks its phantom). + return + + async def _replay_subscriptions(self) -> None: + """Re-issue every desired subscription to the freshly connected proc. + + Each spec is sent as ``refresh-client -B `` with a queued pending + command, so the reader correlates its result block in FIFO order (the + replay commands sit at the front of the deque, ahead of any user command, + because :meth:`start` has not yet returned). The futures are + fire-and-forget: their outcome is swallowed rather than awaited, since the + reader has not started yet. Writing here re-enters neither :meth:`start` + nor :meth:`run_batch`, so the supervisor cannot recurse into itself. + """ + if not self._desired_subscriptions: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for spec in self._desired_subscriptions: + argv = ("refresh-client", "-B", spec) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + + async def _replay_attach(self) -> None: + """Re-attach to every desired session on the freshly connected proc. + + Mirrors :meth:`_replay_subscriptions`. A fresh ``tmux -C`` process is + attached to nothing, and per-pane ``%subscription-changed`` only flows + to an *attached* control client, so the supervisor must re-attach after + every (re)connect or a monitor that relies on the option channel goes + silent. Each target is written as ``attach-session -t `` directly + to stdin with a swallowed pending future (same FIFO + :attr:`_write_lock` + discipline as the subscription replay), so it re-enters neither + :meth:`start` nor :meth:`run_batch`. A control client attaches to one + session at a time, so the last target wins. The attach is fire-and-forget, + so it does not cache :attr:`_attached_session` here; the events layer sets + that on a confirmed attach and re-attaches on a miss. Does nothing when + :attr:`_desired_attach` is empty. + """ + if not self._desired_attach: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for target in self._desired_attach: + argv = ("attach-session", "-t", target) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + # The attach is fire-and-forget (swallowed future): its returncode is + # not awaited, so _attached_session is NOT cached optimistically here. + # The events layer caches it only on a confirmed attach and re-attaches + # on a miss, so a session that vanished during the disconnect surfaces a + # real error instead of a silently-empty capture. + + def _reset_attach(self) -> None: + """Clear the sticky attach so reconnect re-attaches from scratch. + + The events layer caches which session this engine attached to in + :attr:`_attached_session`; a fresh process is attached to nothing, so the + cache must be cleared on every (re)connect. + """ + self._attached_session = None + + @staticmethod + def _backoff(attempt: int) -> float: + """Deterministic jittered exponential backoff (seconds) for *attempt*. + + Capped exponential (``min(0.1 * 2**attempt, 5.0)``) plus a small jitter + derived solely from *attempt* -- never :mod:`random` or wall-clock time -- + so reconnect timing stays reproducible under test. + """ + base = min(0.1 * (2.0**attempt), 5.0) + jitter = 0.01 * float(attempt % 7) + return base + jitter + async def _reader(self) -> None: """Background task: read tmux output, resolve futures, publish events.""" proc = self._proc @@ -479,7 +759,7 @@ def _broadcast_stream_end(self) -> None: self._subscribers.clear() def _mark_dead(self, error: BaseException) -> None: - """Record the engine as dead, fail pending commands, end subscribers.""" + """Record the engine as dead and fail all pending commands.""" if self._dead is None: self._dead = error self._fail_pending(error) diff --git a/tests/experimental/engines/test_async_control_mode_sentinel.py b/tests/experimental/engines/test_async_control_mode_sentinel.py index d3e2ecdf4..f3bdf2029 100644 --- a/tests/experimental/engines/test_async_control_mode_sentinel.py +++ b/tests/experimental/engines/test_async_control_mode_sentinel.py @@ -90,24 +90,3 @@ async def drain() -> list[ControlNotification]: return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang assert asyncio.run(main()) == [] - - -def test_subscribe_ends_immediately_after_death() -> None: - """A subscribe() after the engine died must end at once, not hang. - - _mark_dead() broadcasts the sentinel and clears the subscriber set but does - not flip ``_closing``; a queue registered afterwards would never receive a - sentinel. The ``_dead`` gate makes subscribe() yield nothing and return. - """ - - async def main() -> list[ControlNotification]: - engine = AsyncControlModeEngine() - engine._started = True # pretend a connection was established - engine._mark_dead(ControlModeError("tmux -C closed stdout")) - - async def drain() -> list[ControlNotification]: - return [note async for note in engine.subscribe()] - - return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang - - assert asyncio.run(main()) == [] diff --git a/tests/experimental/engines/test_async_control_mode_supervisor.py b/tests/experimental/engines/test_async_control_mode_supervisor.py new file mode 100644 index 000000000..63fa5176c --- /dev/null +++ b/tests/experimental/engines/test_async_control_mode_supervisor.py @@ -0,0 +1,177 @@ +"""The engine reconnects and replays desired state after the proc dies.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.engines.control_mode import ControlModeError + +if t.TYPE_CHECKING: + import pytest + + from libtmux.server import Server + from libtmux.session import Session + + +def test_desired_subscriptions_recorded_idempotently() -> None: + """``add_subscription`` records desired specs idempotently.""" + engine = AsyncControlModeEngine() + engine.add_subscription("agentstate:%*:#{@agent_state}") + engine.add_subscription("agentstate:%*:#{@agent_state}") # idempotent + assert engine._desired_subscriptions == ["agentstate:%*:#{@agent_state}"] + + +def test_reconnects_after_proc_exits(server: Server) -> None: + """The supervisor reconnects and bumps generation after the proc dies.""" + + async def main() -> int: + engine = AsyncControlModeEngine.for_server(server) + await engine.start() + gen0 = engine._generation + # simulate the control proc dying + assert engine._proc is not None + engine._proc.terminate() + await asyncio.sleep(1.5) # supervisor backoff + reconnect + # a fresh run must succeed over the reconnected proc + from libtmux.experimental.engines.base import CommandRequest + + result = await engine.run(CommandRequest.from_args("list-sessions")) + await engine.aclose() + assert result.returncode == 0 + return engine._generation - gen0 + + bumped = asyncio.run(main()) + assert bumped >= 1 + + +def test_attach_replayed_on_reconnect(session: Session) -> None: + """A reconnect runs the attach replay without optimistically caching. + + A fresh ``tmux -C`` proc is attached to nothing, so the supervisor replays + ``attach-session`` on every (re)connect. That replay is fire-and-forget, so + it must NOT cache ``_attached_session`` -- that cache is owned by the events + layer (set only on a confirmed attach, re-attached on a miss), so a session + that vanished during the disconnect surfaces a real error rather than a + silently-empty capture. This pins that the cache stays unset across a + *reconnect*, not just the first connect. + """ + + async def main() -> str | None: + from libtmux.experimental.engines.base import CommandRequest + + sid = session.session_id + assert sid is not None # a live session always has an id + engine = AsyncControlModeEngine.for_server(session.server) + engine.set_attach_targets([sid]) + await engine.start() + # The first connect replayed the attach but did not cache it. + assert engine._attached_session is None + # Kill the proc; the supervisor reconnects and replays the attach again. + assert engine._proc is not None + engine._proc.terminate() + await asyncio.sleep(1.6) # backoff + reconnect + replay + # A fresh command confirms the reconnected proc is live; by the time it + # returns the reconnect has run past _replay_attach. + await engine.run(CommandRequest.from_args("list-sessions")) + cached = engine._attached_session + await engine.aclose() + return cached + + assert asyncio.run(main()) is None + + +def test_spawn_keeps_dead_until_startup_ack_consumed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_spawn`` clears ``_dead`` only *after* the startup ACK is consumed. + + Across a reconnect ``_connected`` stays set, so a command racing the + reconnect's startup window must still hit the dead-guard rather than have its + reply drained and discarded by ``_consume_startup``. The fix keeps ``_dead`` + set until the ACK is fully consumed; this asserts that ordering deterministically + (no real proc, no timing) by observing ``_dead`` from inside startup. + """ + observed: dict[str, object] = {} + + class _FakeProc: + # _spawn only stores the proc; the overridden _consume_startup never + # reads it, so a bare placeholder process is enough here. + returncode: int | None = None + + class _Probe(AsyncControlModeEngine): + async def _consume_startup(self) -> None: + # liveness state at the instant the startup ACK begins draining + observed["dead_during_startup"] = self._dead + + async def _fake_exec(*_a: object, **_k: object) -> _FakeProc: + return _FakeProc() + + async def main() -> None: + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + engine = _Probe(tmux_bin="tmux") + engine._dead = ControlModeError("prior EOF") # simulate post-disconnect + await engine._spawn() + observed["dead_after"] = engine._dead + + asyncio.run(main()) + assert observed["dead_during_startup"] is not None # dead-guard still active + assert observed["dead_after"] is None # cleared only once the ACK is consumed + + +def test_concurrent_start_all_raise_on_first_connect_failure() -> None: + """Every concurrent ``start()`` raising on a first-connect spawn failure. + + The supervisor records the spawn error in ``_spawn_error`` then returns. The + first ``start()`` waiter must not null it out from under a second concurrent + waiter, or that second caller would observe ``_spawn_error is None`` and + return "success" against a dead engine. Both gathered ``start()`` calls must + raise the same spawn error. + + Deterministic: ``_spawn`` always raises (no real proc, no timing); the two + waiters wake FIFO from the supervisor's single ``_connected.set()``. + """ + + async def main() -> list[object]: + class _Probe(AsyncControlModeEngine): + async def _spawn(self) -> None: + msg = "spawn failed" + raise ControlModeError(msg) + + engine = _Probe() + return list( + await asyncio.gather(engine.start(), engine.start(), return_exceptions=True) + ) + + results = asyncio.run(main()) + assert len(results) == 2 + assert all(isinstance(r, ControlModeError) for r in results) + + +def test_aclose_releases_start_waiter_before_first_connect() -> None: + """``aclose`` racing a never-connected ``start`` must not hang the waiter. + + If the supervisor is cancelled before it ever sets ``_connected``, an + in-flight ``start()`` blocked on ``_connected.wait()`` would hang forever. + The supervisor's ``finally`` plus ``aclose``'s own ``_connected.set()`` release + it deterministically. + """ + + async def main() -> None: + block = asyncio.Event() # never set: the supervisor hangs until cancelled + + class _Probe(AsyncControlModeEngine): + async def _spawn(self) -> None: + await block.wait() # park in spawn, before _connected is ever set + + engine = _Probe() + start_task = asyncio.create_task(engine.start()) + # Let start() launch the supervisor and park on _connected.wait(), and the + # supervisor park in _spawn (so it has entered its try/finally). + for _ in range(5): + await asyncio.sleep(0) + await engine.aclose() # cancels the supervisor before it ever connected + await asyncio.wait_for(start_task, timeout=1.0) # must NOT hang + + asyncio.run(main()) diff --git a/tests/experimental/mcp/test_attach_reset.py b/tests/experimental/mcp/test_attach_reset.py new file mode 100644 index 000000000..0172c5826 --- /dev/null +++ b/tests/experimental/mcp/test_attach_reset.py @@ -0,0 +1,13 @@ +"""A reconnect must clear the sticky attach so %output flows again.""" + +from __future__ import annotations + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +def test_reset_attach_clears_flag() -> None: + """_reset_attach() must set _attached_session to None.""" + engine = AsyncControlModeEngine() + engine._attached_session = "$0" + engine._reset_attach() + assert getattr(engine, "_attached_session", "sentinel") is None From 2eac2dd1a661136a3eab14aed4ef82f51103f286 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:48:34 -0500 Subject: [PATCH 146/223] Mcp(fix[events]): Restart the event drain after reconnect why: The engine's supervisor now reconnects on a tmux restart or socket blip, but _broadcast_stream_end ends the subscribe stream on the disconnect, so the pull ring's drain task completes. A bare ``is None`` guard never re-subscribed, silently freezing the cursor after a reconnect. what: - Restart the drain in _EventRing._ensure_started when the prior task has completed (not just when unset). - Clear a prior drain error at the start of _drain (a fresh attempt) rather than on restart, so a still-unread failure is not wiped before since() surfaces it -- a persistently dead stream must not read as empty-but-healthy. - Cover restart-only-if-done and persistent-error surfacing with parametrized tests. --- src/libtmux/experimental/mcp/events.py | 17 ++- tests/experimental/mcp/test_events.py | 142 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index 3f290e54f..de22a01d5 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -178,8 +178,14 @@ def __init__(self, engine: _StreamEngine, maxlen: int = _RING_SIZE) -> None: self._error: str | None = None def _ensure_started(self) -> None: - """Start the drainer task once, lazily, on the running loop.""" - if self._task is None: + """Start the drainer task, lazily, on the running loop. + + Also restarts it after it has *completed* -- the engine's + ``_broadcast_stream_end`` ends the subscribe stream on a disconnect, so + the drain task finishes; a bare ``is None`` guard would never re-subscribe + after a reconnect, silently freezing the cursor. + """ + if self._task is None or self._task.done(): self._task = asyncio.create_task(self._drain(), name="libtmux-mcp-events") async def _drain(self) -> None: @@ -187,8 +193,13 @@ async def _drain(self) -> None: A reader failure is recorded and surfaced on the next :meth:`since` call, rather than silently freezing the cursor or raising at garbage-collection - time. The subscription is closed deterministically via ``aclosing``. + time. The subscription is closed deterministically via ``aclosing``. The + prior error is cleared here, as this fresh attempt begins -- not in + :meth:`_ensure_started`, so a restart cannot wipe a still-unread failure + before :meth:`since` surfaces it (which would mask a persistently dead + stream as empty-but-healthy). """ + self._error = None stream = t.cast("AsyncGenerator[t.Any, None]", self._engine.subscribe()) try: async with contextlib.aclosing(stream) as managed: diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 079fbe9c9..de775d0ed 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import contextlib import typing as t import pytest @@ -49,6 +50,147 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: _MON_STREAM = (b"%output %1 a b", b"%output %1 c", b"%window-add @9") +class _BlockingStreamEngine: + """An async engine whose ``subscribe()`` never ends (a live stream).""" + + _attached_session: str | None = None + + async def run(self, request: CommandRequest) -> CommandResult: + """Acknowledge any command.""" + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Block forever (a never-ending stream).""" + await asyncio.Event().wait() + yield ControlNotification.parse(b"%unreachable") # present so this is a gen + + +class _RestartCase(t.NamedTuple): + """An EventRing drain state and whether _ensure_started should restart it.""" + + test_id: str + stream_ends: bool # True: prior drain completes; False: still running + expect_restart: bool + + +_RESTART_CASES = ( + _RestartCase("done_drain_restarts", stream_ends=True, expect_restart=True), + _RestartCase("running_drain_kept", stream_ends=False, expect_restart=False), +) + + +@pytest.mark.parametrize( + list(_RestartCase._fields), + _RESTART_CASES, + ids=[c.test_id for c in _RESTART_CASES], +) +def test_event_ring_restarts_completed_drain( + test_id: str, + stream_ends: bool, + expect_restart: bool, +) -> None: + """_ensure_started restarts a *completed* drain (post-reconnect), not a live one.""" + from libtmux.experimental.mcp.events import _EventRing + + async def main() -> bool: + engine = FakeStreamEngine(()) if stream_ends else _BlockingStreamEngine() + ring = _EventRing(engine) + ring.since(0) # starts the drain task + first = ring._task + assert first is not None + if stream_ends: + await first # the empty stream ends, so the drain completes + assert first.done() + ring.since(0) # _ensure_started: restart only if the prior task is done + second = ring._task + restarted = second is not first + for task in {first, second}: + if task is not None and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + return restarted + + assert asyncio.run(main()) is expect_restart + + +class _RaisingStreamEngine: + """An async engine whose ``subscribe()`` raises, so the drain records it.""" + + _attached_session: str | None = None + + async def run(self, request: CommandRequest) -> CommandResult: + """Acknowledge any command.""" + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Fail on first iteration (a permanently dead stream).""" + msg = "engine permanently dead" + raise RuntimeError(msg) + yield ControlNotification.parse(b"%unreachable") # marks this a generator + + +class _DrainErrorCase(t.NamedTuple): + """Whether a drain errored and whether since() must surface an ``error`` key.""" + + test_id: str + raises: bool # True: the drain fails; False: it ends cleanly + expect_error_key: bool + + +_DRAIN_ERROR_CASES = ( + _DrainErrorCase("persistent_error_surfaces", raises=True, expect_error_key=True), + _DrainErrorCase("clean_drain_no_error", raises=False, expect_error_key=False), +) + + +@pytest.mark.parametrize( + list(_DrainErrorCase._fields), + _DRAIN_ERROR_CASES, + ids=[c.test_id for c in _DRAIN_ERROR_CASES], +) +def test_since_surfaces_persistent_drain_error( + test_id: str, + raises: bool, + expect_error_key: bool, +) -> None: + """A failed drain must surface via since(), not be masked by the restart. + + Regression: clearing the error inside _ensure_started wiped it before since() + read it, so a permanently dead stream reported as empty-but-healthy forever. + """ + from libtmux.experimental.mcp.events import _EventRing + + async def main() -> bool: + engine = _RaisingStreamEngine() if raises else FakeStreamEngine(()) + ring = _EventRing(engine) + ring.since(0) # start the first drain + if ring._task is not None: + await ring._task # let it run to completion (error or clean end) + out = ring.since(0) # restarts the drain; a prior error must still show + if ring._task is not None and not ring._task.done(): + ring._task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await ring._task + return "error" in out + + assert asyncio.run(main()) is expect_error_key + + class InstrumentedEngine: """A fake stream engine that records commands and scripts a few responses. From 7940af6524b872de2a9ce86570c52e2c920356c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:48:34 -0500 Subject: [PATCH 147/223] Objects(refactor): Rename facade package to objects why: "Facade" is not idiomatic Python for these engine-typed classes. They are the domain-shaped tmux nouns -- server/session/window/pane/ client -- so the package reads as libtmux.experimental.objects, and an individual engine-bound class is a "wrapper" in prose. Retires the facade/handle vocabulary. what: - Rename src/libtmux/experimental/facade -> objects and its tests, keeping the Eager/Lazy/Async class names unchanged. - Reword package docstrings from "facade"/"handle" to "object". - Update the three stray package references (ops.results, ops.new_pane, docs/experimental) to "wrapper". --- docs/experimental.md | 2 +- .../{facade => objects}/__init__.py | 25 +++++++----- .../{facade => objects}/client.py | 10 ++--- .../experimental/{facade => objects}/pane.py | 38 +++++++++---------- .../{facade => objects}/server.py | 20 +++++----- .../{facade => objects}/session.py | 16 ++++---- .../{facade => objects}/window.py | 22 +++++------ src/libtmux/experimental/ops/_ops/new_pane.py | 2 +- src/libtmux/experimental/ops/results.py | 2 +- tests/experimental/facade/__init__.py | 3 -- tests/experimental/objects/__init__.py | 3 ++ .../test_matrix_complete.py | 4 +- .../test_object_matrix.py} | 12 +++--- .../test_pane_object.py} | 18 ++++----- 14 files changed, 91 insertions(+), 86 deletions(-) rename src/libtmux/experimental/{facade => objects}/__init__.py (52%) rename src/libtmux/experimental/{facade => objects}/client.py (91%) rename src/libtmux/experimental/{facade => objects}/pane.py (91%) rename src/libtmux/experimental/{facade => objects}/server.py (84%) rename src/libtmux/experimental/{facade => objects}/session.py (91%) rename src/libtmux/experimental/{facade => objects}/window.py (89%) delete mode 100644 tests/experimental/facade/__init__.py create mode 100644 tests/experimental/objects/__init__.py rename tests/experimental/{facade => objects}/test_matrix_complete.py (95%) rename tests/experimental/{facade/test_facade_matrix.py => objects/test_object_matrix.py} (88%) rename tests/experimental/{facade/test_pane_facade.py => objects/test_pane_object.py} (89%) diff --git a/docs/experimental.md b/docs/experimental.md index 83ebcf439..8c7c4c873 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -34,7 +34,7 @@ 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 +path raises in its wrapper to match today's libtmux behavior, while the newer engines hand the result back and let the caller decide. ## Choosing an engine diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/objects/__init__.py similarity index 52% rename from src/libtmux/experimental/facade/__init__.py rename to src/libtmux/experimental/objects/__init__.py index 5d1a62943..958fc790e 100644 --- a/src/libtmux/experimental/facade/__init__.py +++ b/src/libtmux/experimental/objects/__init__.py @@ -1,6 +1,11 @@ -"""Engine-typed facades over the operation spine. +"""Engine-typed tmux objects over the operation spine. -The execution mode lives in the facade *type* (eager vs lazy vs async), so each +Each class binds an engine + a tmux id and exposes a curated, ergonomic method +set. The objects are the domain-shaped layer over the Core ``Operation``/engine +spine: the same server/session/window/pane/client nouns as tmux, with the +execution mode carried by the class. + +The execution mode lives in the object *type* (eager vs lazy vs async), so each method has one statically-known return type, while the operation definitions stay shared. The matrix over scope x mode: @@ -14,23 +19,23 @@ client EagerClient LazyClient AsyncClient ========== ============ ============ ============ -Eager handles execute immediately and return live handles; lazy handles record -into a :class:`~..ops.plan.LazyPlan`; async handles await an +Eager objects execute immediately and return live objects; lazy objects record +into a :class:`~..ops.plan.LazyPlan`; async objects await an :class:`~..engines.base.AsyncTmuxEngine`. "Control mode" is not a separate family --- any eager/async facade bound to a ``ControlModeEngine`` already uses it. +-- any eager/async object bound to a ``ControlModeEngine`` already uses it. """ from __future__ import annotations -from libtmux.experimental.facade.client import AsyncClient, EagerClient, LazyClient -from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane -from libtmux.experimental.facade.server import AsyncServer, EagerServer, LazyServer -from libtmux.experimental.facade.session import ( +from libtmux.experimental.objects.client import AsyncClient, EagerClient, LazyClient +from libtmux.experimental.objects.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.objects.server import AsyncServer, EagerServer, LazyServer +from libtmux.experimental.objects.session import ( AsyncSession, EagerSession, LazySession, ) -from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow +from libtmux.experimental.objects.window import AsyncWindow, EagerWindow, LazyWindow __all__ = ( "AsyncClient", diff --git a/src/libtmux/experimental/facade/client.py b/src/libtmux/experimental/objects/client.py similarity index 91% rename from src/libtmux/experimental/facade/client.py rename to src/libtmux/experimental/objects/client.py index 6cd280239..fb8de36de 100644 --- a/src/libtmux/experimental/facade/client.py +++ b/src/libtmux/experimental/objects/client.py @@ -1,8 +1,8 @@ -"""Client-scope facades (eager / lazy / async) over the operation spine. +"""Client-scope objects (eager / lazy / async) over the operation spine. A client is a *view* (a terminal attachment keyed by name/tty), not part of the ownership chain, but tmux exposes client-scoped commands -- ``detach-client``, -``switch-client``, ``refresh-client`` -- so it gets a facade like any other scope. +``switch-client``, ``refresh-client`` -- so it gets a object like any other scope. """ from __future__ import annotations @@ -27,7 +27,7 @@ @dataclass(frozen=True) class EagerClient: - """A live client handle; methods execute immediately. + """A live client object; methods execute immediately. Examples -------- @@ -70,7 +70,7 @@ def switch_to(self, session_id: str) -> Result: @dataclass(frozen=True) class LazyClient: - """A deferred client handle; methods record into a plan.""" + """A deferred client object; methods record into a plan.""" plan: LazyPlan client_name: str @@ -93,7 +93,7 @@ def switch_to(self, session_id: str) -> LazyClient: @dataclass(frozen=True) class AsyncClient: - """An async live client handle: the eager client, awaited.""" + """An async live client object: the eager client, awaited.""" engine: AsyncTmuxEngine client_name: str diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/objects/pane.py similarity index 91% rename from src/libtmux/experimental/facade/pane.py rename to src/libtmux/experimental/objects/pane.py index 9dba79910..419642a30 100644 --- a/src/libtmux/experimental/facade/pane.py +++ b/src/libtmux/experimental/objects/pane.py @@ -1,18 +1,18 @@ -"""Pane-scope facades demonstrating "mode lives in the type". +"""Pane-scope objects demonstrating "mode lives in the type". -Two thin facades over the *same* operation spine show why the execution mode +Two thin objects over the *same* operation spine show why the execution mode belongs in the class rather than a runtime flag: -- :class:`EagerPane` executes immediately and returns *live* handles +- :class:`EagerPane` executes immediately and returns *live* objects (``split()`` -> :class:`EagerPane`), so its return types are concrete. - :class:`LazyPane` records into a :class:`~libtmux.experimental.ops.plan.LazyPlan` - and returns *deferred* handles (``split()`` -> :class:`LazyPane`), executing + and returns *deferred* objects (``split()`` -> :class:`LazyPane`), executing only when the plan runs. Each ``split()`` therefore has exactly one statically-known return type -- a single ``Pane`` class with a runtime engine attribute could not express that. The same :class:`~libtmux.experimental.ops.SplitWindow` operation backs both; -only the facade differs. This is the seed of the wider facade matrix +only the object differs. This is the seed of the wider object matrix (``AsyncPane``, ``LazyControlWindow``, ...) described in issue 689. """ @@ -58,7 +58,7 @@ def _new_pane_op( message: str | None, shell_command: str | None, ) -> NewPane: - """Build a :class:`~..ops.NewPane` for *target* (shared by the facades).""" + """Build a :class:`~..ops.NewPane` for *target* (shared by the objects).""" return NewPane( target=target, width=width, @@ -80,7 +80,7 @@ def _new_pane_op( @dataclass(frozen=True) class EagerPane: - """A live pane handle bound to an engine; methods execute immediately. + """A live pane object bound to an engine; methods execute immediately. Parameters ---------- @@ -116,7 +116,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> EagerPane: - """Split this pane and return a live handle to the new pane.""" + """Split this pane and return a live object to the new pane.""" result = run( SplitWindow( target=PaneId(self.pane_id), @@ -149,7 +149,7 @@ def new_pane( message: str | None = None, shell_command: str | None = None, ) -> EagerPane: - """Create a floating pane (tmux 3.7+) and return a live handle to it.""" + """Create a floating pane (tmux 3.7+) and return a live object to it.""" result = run( _new_pane_op( PaneId(self.pane_id), @@ -196,14 +196,14 @@ def capture( @dataclass(frozen=True) class LazyPane: - """A deferred pane handle; methods record into a plan instead of running. + """A deferred pane object; methods record into a plan instead of running. Parameters ---------- plan : LazyPlan The plan operations are recorded into. ref : Target - The target this handle addresses (a concrete id, or a SlotRef for a + The target this object addresses (a concrete id, or a SlotRef for a pane created earlier in the plan). Examples @@ -232,7 +232,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> LazyPane: - """Record a split; return a deferred handle to the pane it will create.""" + """Record a split; return a deferred object to the pane it will create.""" slot = self.plan.add( SplitWindow( target=self.ref, @@ -261,7 +261,7 @@ def new_pane( message: str | None = None, shell_command: str | None = None, ) -> LazyPane: - """Record a floating-pane creation; return a deferred handle to it.""" + """Record a floating-pane creation; return a deferred object to it.""" slot = self.plan.add( _new_pane_op( self.ref, @@ -284,23 +284,23 @@ def new_pane( return LazyPane(self.plan, slot) def send_keys(self, keys: str, *, enter: bool = False) -> LazyPane: - """Record a send-keys against this handle; return self for chaining.""" + """Record a send-keys against this object; return self for chaining.""" self.plan.add(SendKeys(target=self.ref, keys=keys, enter=enter)) return self def capture(self, *, start: int | None = None, end: int | None = None) -> LazyPane: - """Record a capture against this handle; return self for chaining.""" + """Record a capture against this object; return self for chaining.""" self.plan.add(CapturePane(target=self.ref, start=start, end=end)) return self @dataclass(frozen=True) class AsyncPane: - """An async live pane handle: the eager pane, awaited. + """An async live pane object: the eager pane, awaited. Identical in shape to :class:`EagerPane` -- same operations, same spine -- but bound to an :class:`~..engines.base.AsyncTmuxEngine` and awaited. This is - why async is a sibling facade, not a transformation. + why async is a sibling object, not a transformation. Examples -------- @@ -325,7 +325,7 @@ async def split( start_directory: str | None = None, shell: str | None = None, ) -> AsyncPane: - """Split this pane and return a live async handle to the new pane.""" + """Split this pane and return a live async object to the new pane.""" result = await arun( SplitWindow( target=PaneId(self.pane_id), @@ -358,7 +358,7 @@ async def new_pane( message: str | None = None, shell_command: str | None = None, ) -> AsyncPane: - """Create a floating pane (tmux 3.7+) and return a live async handle.""" + """Create a floating pane (tmux 3.7+) and return a live async object.""" result = await arun( _new_pane_op( PaneId(self.pane_id), diff --git a/src/libtmux/experimental/facade/server.py b/src/libtmux/experimental/objects/server.py similarity index 84% rename from src/libtmux/experimental/facade/server.py rename to src/libtmux/experimental/objects/server.py index b10f6da7d..085d6cb16 100644 --- a/src/libtmux/experimental/facade/server.py +++ b/src/libtmux/experimental/objects/server.py @@ -1,11 +1,11 @@ -"""Server-scope facades -- the entry points for facade navigation.""" +"""Server-scope objects -- the entry points for object navigation.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.session import ( +from libtmux.experimental.objects.session import ( AsyncSession, EagerSession, LazySession, @@ -19,7 +19,7 @@ @dataclass(frozen=True) class EagerServer: - """A live server handle; the root of eager facade navigation. + """A live server object; the root of eager object navigation. Examples -------- @@ -42,7 +42,7 @@ def new_session( name: str | None = None, start_directory: str | None = None, ) -> EagerSession: - """Create a detached session; return a live session handle.""" + """Create a detached session; return a live session object.""" result = run( NewSession(session_name=name, start_directory=start_directory), self.engine, @@ -54,7 +54,7 @@ def new_session( @classmethod def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer: - """Bind an eager facade to a live :class:`libtmux.Server`'s classic engine.""" + """Bind an eager object to a live :class:`libtmux.Server`'s classic engine.""" from libtmux.experimental.engines import SubprocessEngine return cls(SubprocessEngine.for_server(server), version=version) @@ -62,7 +62,7 @@ def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer @dataclass(frozen=True) class LazyServer: - """A deferred server handle; records session creation into a plan. + """A deferred server object; records session creation into a plan. Examples -------- @@ -84,7 +84,7 @@ def new_session( name: str | None = None, start_directory: str | None = None, ) -> LazySession: - """Record a new session; return a deferred session handle.""" + """Record a new session; return a deferred session object.""" slot = self.plan.add( NewSession(session_name=name, start_directory=start_directory), ) @@ -93,7 +93,7 @@ def new_session( @dataclass(frozen=True) class AsyncServer: - """An async live server handle: the eager server, awaited.""" + """An async live server object: the eager server, awaited.""" engine: AsyncTmuxEngine version: str | None = None @@ -104,7 +104,7 @@ async def new_session( name: str | None = None, start_directory: str | None = None, ) -> AsyncSession: - """Create a detached session; return a live async session handle.""" + """Create a detached session; return a live async session object.""" result = await arun( NewSession(session_name=name, start_directory=start_directory), self.engine, @@ -116,7 +116,7 @@ async def new_session( @classmethod def for_server(cls, server: t.Any, *, version: str | None = None) -> AsyncServer: - """Bind an async facade to a live :class:`libtmux.Server`'s socket.""" + """Bind an async object to a live :class:`libtmux.Server`'s socket.""" from libtmux.experimental.engines import AsyncSubprocessEngine return cls(AsyncSubprocessEngine.for_server(server), version=version) diff --git a/src/libtmux/experimental/facade/session.py b/src/libtmux/experimental/objects/session.py similarity index 91% rename from src/libtmux/experimental/facade/session.py rename to src/libtmux/experimental/objects/session.py index 83cc0d40d..14c1c1447 100644 --- a/src/libtmux/experimental/facade/session.py +++ b/src/libtmux/experimental/objects/session.py @@ -1,11 +1,11 @@ -"""Session-scope facades (eager / lazy / async) over the operation spine.""" +"""Session-scope objects (eager / lazy / async) over the operation spine.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow +from libtmux.experimental.objects.window import AsyncWindow, EagerWindow, LazyWindow from libtmux.experimental.ops import ( KillSession, NewWindow, @@ -24,7 +24,7 @@ @dataclass(frozen=True) class EagerSession: - """A live session handle; methods execute immediately. + """A live session object; methods execute immediately. Examples -------- @@ -47,7 +47,7 @@ def new_window( name: str | None = None, start_directory: str | None = None, ) -> EagerWindow: - """Create a window in this session; return a live window handle.""" + """Create a window in this session; return a live window object.""" result = run( NewWindow( target=SessionId(self.session_id), @@ -80,7 +80,7 @@ def kill(self) -> Result: @dataclass(frozen=True) class LazySession: - """A deferred session handle; methods record into a plan. + """A deferred session object; methods record into a plan. Examples -------- @@ -104,7 +104,7 @@ def new_window( name: str | None = None, start_directory: str | None = None, ) -> LazyWindow: - """Record a new window; return a deferred window handle.""" + """Record a new window; return a deferred window object.""" slot = self.plan.add( NewWindow(target=self.ref, name=name, start_directory=start_directory), ) @@ -123,7 +123,7 @@ def kill(self) -> LazySession: @dataclass(frozen=True) class AsyncSession: - """An async live session handle: the eager session, awaited.""" + """An async live session object: the eager session, awaited.""" engine: AsyncTmuxEngine session_id: str @@ -135,7 +135,7 @@ async def new_window( name: str | None = None, start_directory: str | None = None, ) -> AsyncWindow: - """Create a window in this session; return a live async window handle.""" + """Create a window in this session; return a live async window object.""" result = await arun( NewWindow( target=SessionId(self.session_id), diff --git a/src/libtmux/experimental/facade/window.py b/src/libtmux/experimental/objects/window.py similarity index 89% rename from src/libtmux/experimental/facade/window.py rename to src/libtmux/experimental/objects/window.py index f3a2476f9..7bb827c09 100644 --- a/src/libtmux/experimental/facade/window.py +++ b/src/libtmux/experimental/objects/window.py @@ -1,9 +1,9 @@ -"""Window-scope facades (eager / lazy / async) over the operation spine. +"""Window-scope objects (eager / lazy / async) over the operation spine. -Mirrors the pane facades one scope up: an :class:`EagerWindow` executes now and -returns live handles (``split()`` -> :class:`~.pane.EagerPane`), a +Mirrors the pane objects one scope up: an :class:`EagerWindow` executes now and +returns live objects (``split()`` -> :class:`~.pane.EagerPane`), a :class:`LazyWindow` records into a plan, and an :class:`AsyncWindow` awaits. All -three drive the *same* window-scope operations; only the facade differs. +three drive the *same* window-scope operations; only the object differs. """ from __future__ import annotations @@ -11,7 +11,7 @@ import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.objects.pane import AsyncPane, EagerPane, LazyPane from libtmux.experimental.ops import ( KillWindow, RenameWindow, @@ -31,7 +31,7 @@ @dataclass(frozen=True) class EagerWindow: - """A live window handle bound to an engine; methods execute immediately. + """A live window object bound to an engine; methods execute immediately. Examples -------- @@ -55,7 +55,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> EagerPane: - """Split this window's active pane; return a live pane handle.""" + """Split this window's active pane; return a live pane object.""" result = run( SplitWindow( target=WindowId(self.window_id), @@ -97,7 +97,7 @@ def kill(self) -> Result: @dataclass(frozen=True) class LazyWindow: - """A deferred window handle; methods record into a plan. + """A deferred window object; methods record into a plan. Examples -------- @@ -123,7 +123,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> LazyPane: - """Record a split; return a deferred pane handle to the new pane.""" + """Record a split; return a deferred pane object to the new pane.""" slot = self.plan.add( SplitWindow( target=self.ref, @@ -152,7 +152,7 @@ def kill(self) -> LazyWindow: @dataclass(frozen=True) class AsyncWindow: - """An async live window handle: the eager window, awaited.""" + """An async live window object: the eager window, awaited.""" engine: AsyncTmuxEngine window_id: str @@ -165,7 +165,7 @@ async def split( start_directory: str | None = None, shell: str | None = None, ) -> AsyncPane: - """Split this window's active pane; return a live async pane handle.""" + """Split this window's active pane; return a live async pane object.""" result = await arun( SplitWindow( target=WindowId(self.window_id), diff --git a/src/libtmux/experimental/ops/_ops/new_pane.py b/src/libtmux/experimental/ops/_ops/new_pane.py index c4398bb37..79b1d1fd4 100644 --- a/src/libtmux/experimental/ops/_ops/new_pane.py +++ b/src/libtmux/experimental/ops/_ops/new_pane.py @@ -30,7 +30,7 @@ class NewPane(Operation[SplitWindowResult]): Like :class:`~.split_window.SplitWindow` it reuses :class:`~.results.SplitWindowResult`, capturing the new pane id via - ``-P -F '#{pane_id}'`` so plans, the facade, and MCP bind it the same way. + ``-P -F '#{pane_id}'`` so plans, the wrapper, and MCP bind it the same way. Rendering against a tmux older than 3.7 raises :exc:`~.exc.VersionUnsupported` (this op sets diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index ebd41b19e..882cdc870 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -9,7 +9,7 @@ Results never raise on construction. Raising is opt-in via :meth:`Result.raise_for_status`, mirroring :meth:`subprocess.CompletedProcess.check_returncode`. *How* an engine treats a -failed result is the engine's policy: the classic engine raises in its facade to +failed result is the engine's policy: the classic engine raises in its wrapper to match today's behavior, while newer engines hand the result back and let the caller decide. """ diff --git a/tests/experimental/facade/__init__.py b/tests/experimental/facade/__init__.py deleted file mode 100644 index a260ebd4d..000000000 --- a/tests/experimental/facade/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for libtmux.experimental.facade.""" - -from __future__ import annotations diff --git a/tests/experimental/objects/__init__.py b/tests/experimental/objects/__init__.py new file mode 100644 index 000000000..91862c9c7 --- /dev/null +++ b/tests/experimental/objects/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.objects.""" + +from __future__ import annotations diff --git a/tests/experimental/facade/test_matrix_complete.py b/tests/experimental/objects/test_matrix_complete.py similarity index 95% rename from tests/experimental/facade/test_matrix_complete.py rename to tests/experimental/objects/test_matrix_complete.py index b5381a168..f20495415 100644 --- a/tests/experimental/facade/test_matrix_complete.py +++ b/tests/experimental/objects/test_matrix_complete.py @@ -1,11 +1,11 @@ -"""Tests completing the facade matrix: lazy/async Server+Session and Client.""" +"""Tests completing the object matrix: lazy/async Server+Session and Client.""" from __future__ import annotations import asyncio from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine -from libtmux.experimental.facade import ( +from libtmux.experimental.objects import ( AsyncClient, AsyncServer, EagerClient, diff --git a/tests/experimental/facade/test_facade_matrix.py b/tests/experimental/objects/test_object_matrix.py similarity index 88% rename from tests/experimental/facade/test_facade_matrix.py rename to tests/experimental/objects/test_object_matrix.py index eefab9457..7956da80d 100644 --- a/tests/experimental/facade/test_facade_matrix.py +++ b/tests/experimental/objects/test_object_matrix.py @@ -1,4 +1,4 @@ -"""Tests for the facade matrix (scope x mode) over the shared spine.""" +"""Tests for the object matrix (scope x mode) over the shared spine.""" from __future__ import annotations @@ -6,7 +6,7 @@ import typing as t from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine -from libtmux.experimental.facade import ( +from libtmux.experimental.objects import ( AsyncPane, AsyncWindow, EagerPane, @@ -55,7 +55,7 @@ def test_lazy_window_records_and_executes() -> None: def test_async_window_and_pane() -> None: - """Async facades mirror the eager ones via await.""" + """Async objects mirror the eager ones via await.""" async def main() -> tuple[str, bool, bool]: window = AsyncWindow(AsyncConcreteEngine(), "@1") @@ -72,11 +72,11 @@ async def main() -> tuple[str, bool, bool]: def test_eager_navigation_live(session: Session) -> None: - """Eager facade builds a real session/window/pane against tmux, then cleans up.""" + """Eager object builds a real session/window/pane against tmux, then cleans up.""" server = session.server - facade = EagerServer.for_server(server) + server_obj = EagerServer.for_server(server) - created = facade.new_session(name="facade-matrix-test") + created = server_obj.new_session(name="object-matrix-test") try: assert created.session_id.startswith("$") assert server.sessions.get(session_id=created.session_id) is not None diff --git a/tests/experimental/facade/test_pane_facade.py b/tests/experimental/objects/test_pane_object.py similarity index 89% rename from tests/experimental/facade/test_pane_facade.py rename to tests/experimental/objects/test_pane_object.py index b74ed3daf..448ff7c94 100644 --- a/tests/experimental/facade/test_pane_facade.py +++ b/tests/experimental/objects/test_pane_object.py @@ -1,16 +1,16 @@ -"""Tests for the eager and lazy pane facades.""" +"""Tests for the eager and lazy pane objects.""" from __future__ import annotations from libtmux.experimental.engines import ConcreteEngine -from libtmux.experimental.facade import EagerPane, LazyPane +from libtmux.experimental.objects import EagerPane, LazyPane from libtmux.experimental.ops import LazyPlan from libtmux.experimental.ops._types import PaneId from libtmux.experimental.ops.results import SplitWindowResult def test_eager_split_returns_live_pane() -> None: - """EagerPane.split executes now and returns a live EagerPane handle.""" + """EagerPane.split executes now and returns a live EagerPane object.""" pane = EagerPane(ConcreteEngine(), "%0") child = pane.split(horizontal=True) assert isinstance(child, EagerPane) @@ -25,7 +25,7 @@ def test_eager_capture_and_send() -> None: assert pane.send_keys("echo hi", enter=True).ok -def test_lazy_split_returns_deferred_handle_and_defers() -> None: +def test_lazy_split_returns_deferred_object_and_defers() -> None: """LazyPane.split records into a plan and returns a deferred LazyPane.""" plan = LazyPlan() root = LazyPane(plan, PaneId("%0")) @@ -49,7 +49,7 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: def test_eager_new_pane_returns_live_pane() -> None: - """EagerPane.new_pane creates a floating pane and returns a live handle.""" + """EagerPane.new_pane creates a floating pane and returns a live object.""" pane = EagerPane(ConcreteEngine(), "%0") floating = pane.new_pane(width=80, height=20, x=5, y=3) assert isinstance(floating, EagerPane) @@ -75,11 +75,11 @@ def test_lazy_new_pane_records_new_pane_op() -> None: def test_async_new_pane_returns_live_pane() -> None: - """AsyncPane.new_pane creates a floating pane and returns a live handle.""" + """AsyncPane.new_pane creates a floating pane and returns a live object.""" import asyncio from libtmux.experimental.engines import AsyncConcreteEngine - from libtmux.experimental.facade import AsyncPane + from libtmux.experimental.objects import AsyncPane async def main() -> str: pane = AsyncPane(AsyncConcreteEngine(), "%0") @@ -89,8 +89,8 @@ async def main() -> str: assert asyncio.run(main()) == "%1" -def test_same_operation_backs_both_facades() -> None: - """Eager and lazy facades render the identical underlying operation argv.""" +def test_same_operation_backs_both_objects() -> None: + """Eager and lazy objects render the identical underlying operation argv.""" eager_engine = ConcreteEngine() eager = EagerPane(eager_engine, "%0") # Capture the eager split's rendered argv via the engine-independent op. From 836f92241b2e68882f50804cd6184ed807cd3cca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:59:56 -0500 Subject: [PATCH 148/223] Engines(fix[async_control_mode]): Escalate backoff on connect-then-die why: A proc that connects then instantly dies still consumes its startup ACK, so the supervisor counted it as a healthy connection and reset the reconnect backoff to zero every loop. A persistently flapping proc (fatal config, permission, or resource error) then fork-stormed tmux at ~10 Hz instead of backing off. what: - Reset the backoff only for a connection that survived a minimum lifetime; a connect-then-immediately-die counts as a failed attempt, so the backoff escalates. - Add _HEALTHY_CONNECTION_SECONDS and a regression test asserting a connect-then-die loop escalates instead of pinning at _backoff(0). --- .../engines/async_control_mode.py | 23 ++++++++---- .../test_async_control_mode_supervisor.py | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 4de396497..6a7bac1e8 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -60,6 +60,10 @@ _DEFAULT_TIMEOUT = 30.0 _STARTUP_TIMEOUT = 5.0 _STOP_TIMEOUT = 2.0 +# A connection must survive at least this long to count as healthy and reset the +# reconnect backoff; a shorter-lived one is treated as a failed attempt so a +# persistently flapping proc escalates instead of fork-storming. +_HEALTHY_CONNECTION_SECONDS = 1.0 _STREAM_END = object() # broadcast to subscriber queues to end their async for @@ -542,12 +546,11 @@ async def _supervisor(self) -> None: await asyncio.sleep(self._backoff(attempt)) attempt += 1 continue - # The spawn succeeded and its startup ACK was consumed, so this - # connection is healthy: reset the backoff. A reconnect after a - # long healthy session then starts fast instead of waiting near - # the cap -- only *consecutive connect failures* (the except path - # above) escalate. - attempt = 0 + # The spawn succeeded and its startup ACK was consumed. Do NOT + # reset the backoff yet: a proc that connects then immediately + # dies (reader EOF within the grace) is not a healthy session, and + # resetting here would pin every reconnect at _backoff(0) and + # fork-storm tmux. The reset is gated on connection lifetime below. self._generation += 1 connected_once = True await self._reap_own_session() @@ -556,9 +559,17 @@ async def _supervisor(self) -> None: self._connected.set() # first connect done: unblock start() # The reader runs inline (one reader at a time). On EOF it returns # and we reconnect; on cancellation (aclose) it propagates out. + loop = asyncio.get_running_loop() + connected_at = loop.time() await self._reader() if self._closing: return + # Only a connection that survived a meaningful interval resets the + # backoff; a connect-then-immediately-die counts as a failed + # attempt, so a persistently flapping proc escalates instead of + # spinning at _backoff(0). + if loop.time() - connected_at >= _HEALTHY_CONNECTION_SECONDS: + attempt = 0 await asyncio.sleep(self._backoff(attempt)) attempt += 1 finally: diff --git a/tests/experimental/engines/test_async_control_mode_supervisor.py b/tests/experimental/engines/test_async_control_mode_supervisor.py index 63fa5176c..58cf667a4 100644 --- a/tests/experimental/engines/test_async_control_mode_supervisor.py +++ b/tests/experimental/engines/test_async_control_mode_supervisor.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import typing as t from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine @@ -175,3 +176,38 @@ async def _spawn(self) -> None: await asyncio.wait_for(start_task, timeout=1.0) # must NOT hang asyncio.run(main()) + + +def test_connect_then_die_escalates_backoff() -> None: + """A flapping connect-then-die escalates the backoff, not a fixed spin. + + Regression: the supervisor reset ``attempt`` to 0 on every spawn-success, so a + proc that connected then immediately EOF'd kept reconnecting at ``_backoff(0)`` + forever instead of escalating. The reset is now gated on connection lifetime. + """ + seen: list[int] = [] + + class _Probe(AsyncControlModeEngine): + async def _spawn(self) -> None: + return # connect "succeeds" instantly; _proc stays None + + async def _reader(self) -> None: + return # reader returns at once: a connect-then-die + + @staticmethod + def _backoff(attempt: int) -> float: + seen.append(attempt) + return 0.0 # no real delay, so the test runs fast + + async def main() -> None: + engine = _Probe() + task = asyncio.create_task(engine._supervisor()) + for _ in range(30): # let several connect-then-die iterations run + await asyncio.sleep(0) + engine._closing = True + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + asyncio.run(main()) + assert seen[:3] == [0, 1, 2] # escalates, not pinned at 0 From b7fe0bb177b69b63f5690bf12de559d53fca21bc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 09:01:06 -0500 Subject: [PATCH 149/223] Engines(fix[async_control_mode]): Terminate the prior proc on reconnect why: A reader that returned via an exception (not a clean EOF) leaves the tmux -C process alive. The supervisor then reconnected and _spawn overwrote _proc with the new process without terminating the old one, orphaning a control client on every such reconnect. what: - Terminate a still-alive prior proc at the top of _spawn before replacing it; a clean-EOF proc has already exited (no-op). - Add a regression test that _spawn terminates a live prior proc. --- .../engines/async_control_mode.py | 8 ++++ .../test_async_control_mode_supervisor.py | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 6a7bac1e8..5e8a65096 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -311,6 +311,14 @@ async def _spawn(self) -> None: dead-guard). The caller is responsible for resetting the parser *before* this runs, so the new process's startup bytes are parsed by a fresh parser. """ + # A reader that returned via an exception (not a clean EOF) leaves the + # prior tmux -C alive; terminate it before overwriting _proc so a + # reconnect never orphans a control client. A clean-EOF proc has already + # exited, so this is a no-op there. + old = self._proc + if old is not None and old.returncode is None: + with contextlib.suppress(ProcessLookupError): + old.terminate() tmux_bin = self.tmux_bin or shutil.which("tmux") if tmux_bin is None: raise exc.TmuxCommandNotFound diff --git a/tests/experimental/engines/test_async_control_mode_supervisor.py b/tests/experimental/engines/test_async_control_mode_supervisor.py index 58cf667a4..da7092c92 100644 --- a/tests/experimental/engines/test_async_control_mode_supervisor.py +++ b/tests/experimental/engines/test_async_control_mode_supervisor.py @@ -211,3 +211,40 @@ async def main() -> None: asyncio.run(main()) assert seen[:3] == [0, 1, 2] # escalates, not pinned at 0 + + +def test_spawn_terminates_a_live_prior_proc( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_spawn terminates a still-alive prior proc so a reconnect can't orphan it. + + On a reader-EXCEPTION reconnect (vs a clean EOF) the old tmux -C is still + alive; overwriting _proc without terminating it would leak a control client. + """ + terminated: list[bool] = [] + + class _AliveProc: + returncode: int | None = None + + def terminate(self) -> None: + terminated.append(True) + + class _NewProc: + returncode: int | None = None + + async def _fake_exec(*_a: object, **_k: object) -> _NewProc: + return _NewProc() + + class _Probe(AsyncControlModeEngine): + async def _consume_startup(self) -> None: + return # skip the real startup drain (the fake proc has no stdout) + + async def main() -> None: + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + engine = _Probe(tmux_bin="tmux") + # a prior, still-alive control client (as a reader-exception reconnect leaves) + engine._proc = t.cast("asyncio.subprocess.Process", _AliveProc()) + await engine._spawn() + + asyncio.run(main()) + assert terminated == [True] # the prior live proc was terminated From 95a96f286ce55ccb298b96db85242ba7982564ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 11:12:06 -0500 Subject: [PATCH 150/223] Scripts(feat[bench]): Add hermetic engine build-benchmark grid why: Quantify the experimental workspace builder's build cost across engines and answer "which engine, how fast" reproducibly, without ever touching the developer's live tmux server. what: - Add scripts/bench_engines.py, a self-contained PEP 723 script (uv run) that sweeps scenarios x engines x wait-modes and reports min/avg/median/p90/p95/p99/max as rich tables + JSON. - Engines: classic; the builder on subprocess/control_mode/imsg/ concrete; and a pipelined prototype that batches independent creates via run_batch. - Sandboxed: per-run isolated sockets under a throwaway dir, TMUX unset, atexit teardown + orphan backstop -- the default server is never contacted. - Subcommands: run (in-process grid), cell (one build, for hyperfine), profile (cProfile). --- scripts/bench_engines.py | 470 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 scripts/bench_engines.py diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py new file mode 100644 index 000000000..50fd12db8 --- /dev/null +++ b/scripts/bench_engines.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["rich>=13", "typer>=0.12", "libtmux"] +# +# [tool.uv.sources] +# libtmux = { path = "..", editable = true } +# /// +"""Hermetic libtmux engine build-benchmark grid. + +Measures how long the experimental workspace builder (and the classic API, and a +hand-rolled pipelined prototype) take to build tmux session structures, sweeping +scenarios x engines x wait-modes. Reports min/avg/median/max/p90/p95/p99. + +Hermetic & sandboxed: every server runs on its OWN socket under a throwaway +mkdtemp dir; ``TMUX`` is unset at import so the ambient session is never touched; +an ``atexit`` hook kills every spawned server (and any orphan on our sockets) and +removes the dir. The default server is never contacted. + +Engines (``--engines``): + classic classic libtmux Server/Session/Window/Pane API (subprocess) + subprocess builder on SubprocessEngine (one tmux fork per op) + control_mode builder on ControlModeEngine (one persistent ``tmux -C``) + imsg builder on ImsgEngine (AF_UNIX imsg, socket-injected) + concrete builder on ConcreteEngine (offline, in-memory: Python floor) + pipelined prototype: batch independent creates via run_batch (control_mode) + +Timing (``run`` = in-process build-only, the clean signal; ``--hyperfine`` also +runs whole-process wall time via hyperfine over the ``cell`` subcommand). + +Run: uv run bench_engines.py run + uv run bench_engines.py run --engines control_mode,pipelined --wait + uv run bench_engines.py profile --engine control_mode --shape 8x4 + uv run bench_engines.py cell control_mode 8x4 # one build (for hyperfine) +""" + +from __future__ import annotations + +import atexit +import contextlib +import cProfile +import dataclasses +import io +import itertools +import json +import math +import os +import pathlib +import pstats +import shutil +import statistics +import subprocess +import tempfile +import time +import typing as t +import uuid + +# Never inherit the ambient tmux session -- do this BEFORE importing libtmux. +os.environ.pop("TMUX", None) +os.environ.pop("TMUX_PANE", None) + +import rich.console +import rich.table +import typer + +from libtmux.experimental.engines import ( + ConcreteEngine, + ControlModeEngine, + ImsgEngine, + SubprocessEngine, +) +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.workspace import Pane, Window, Workspace +from libtmux.server import Server + +console = rich.console.Console() +R = CommandRequest.from_args +_ctr = itertools.count() +STAT_LABELS = ("n", "min", "avg", "median", "p90", "p95", "p99", "max") + +# --------------------------------------------------------------------------- # +# Hermetic isolation # +# --------------------------------------------------------------------------- # +_SOCK_DIR = pathlib.Path( + tempfile.mkdtemp(prefix="ltbench-") +) # short: /tmp/ltbench-XXXX +_SERVERS: list[Server] = [] + + +def new_server() -> Server: + """Return a fresh isolated server on a unique socket under the scratch dir.""" + srv = Server(socket_path=str(_SOCK_DIR / f"{uuid.uuid4().hex[:8]}.sock")) + _SERVERS.append(srv) + return srv + + +def _cleanup() -> None: + for srv in _SERVERS: + with contextlib.suppress(Exception): + srv.kill() + # Backstop: SIGKILL any tmux server still bound to a socket in our dir. + with contextlib.suppress(Exception): + out = subprocess.run( + ["pgrep", "-f", f"tmux .*-S{_SOCK_DIR}/"], + capture_output=True, + text=True, + check=False, + ).stdout.split() + for pid in out: + with contextlib.suppress(Exception): + os.kill(int(pid), 9) + with contextlib.suppress(Exception): + shutil.rmtree(_SOCK_DIR, ignore_errors=True) + + +atexit.register(_cleanup) + + +def uniq() -> str: + """Return a process-unique session name (never collides across builds).""" + return f"b{next(_ctr)}" + + +# --------------------------------------------------------------------------- # +# Scenario spec + build implementations # +# --------------------------------------------------------------------------- # +def parse_shape(s: str) -> tuple[int, int]: + """'8x4' -> (8 windows, 4 panes-per-window).""" + w, _, p = s.lower().partition("x") + return int(w), int(p) + + +def spec(name: str, wins: int, panes: int) -> Workspace: + """Build the declarative Workspace IR for *wins* windows x *panes* panes.""" + return Workspace( + name=name, + on_exists="replace", + windows=[ + Window(name=f"w{w}", panes=[Pane() for _ in range(panes)]) + for w in range(wins) + ], + ) + + +def build_classic(server: Server, name: str, wins: int, panes: int) -> None: + """Build the structure with the classic Server/Session/Window/Pane API.""" + session = server.new_session(session_name=name, window_name="w0") + for _ in range(panes - 1): + session.active_window.split() + for wi in range(1, wins): + window = session.new_window(window_name=f"w{wi}") + for _ in range(panes - 1): + window.split() + + +def build_pipelined(engine: t.Any, name: str, wins: int, panes: int) -> None: + """Prototype: batch INDEPENDENT creates into few run_batch round-trips. + + new-session (1) + all new-windows in one run_batch (1) + all splits in one + run_batch (1) = 3 round-trips for any shape, vs ~1-per-op for the builder. + The control-mode run_batch pipelines (write all, read all reply blocks). + """ + engine.run(R("new-session", "-d", "-s", name, "-n", "w0")) + if wins > 1: + engine.run_batch( + [R("new-window", "-t", name, "-n", f"w{i}") for i in range(1, wins)] + ) + splits = [ + R("split-window", "-t", f"{name}:w{i}") + for i in range(wins) + for _ in range(panes - 1) + ] + if splits: + engine.run_batch(splits) + + +class ImsgForServer: + """Bind ImsgEngine to a specific server by injecting ``-S`` per call. + + ImsgEngine has no ``for_server`` -- it parses ``-L``/``-S`` from the command + args -- so this wrapper prepends the isolated socket flag to every request. + """ + + def __init__(self, server: Server) -> None: + self._e = ImsgEngine() + self._flag = ( + f"-S{server.socket_path}" + if server.socket_path + else f"-L{server.socket_name}" + ) + + def run(self, req: CommandRequest) -> t.Any: + """Run one request with the socket flag injected.""" + return self._e.run(R(self._flag, *req.args)) + + def run_batch(self, reqs: t.Sequence[CommandRequest]) -> t.Any: + """Run a batch of requests, each with the socket flag injected.""" + return self._e.run_batch([R(self._flag, *r.args) for r in reqs]) + + def tmux_version(self) -> t.Any: + """Report the underlying imsg engine's tmux version.""" + return self._e.tmux_version() + + +@dataclasses.dataclass(frozen=True) +class Impl: + """One benchmarked implementation: how to make its engine and build.""" + + name: str + kind: str # classic | builder | pipelined | offline + make_engine: t.Callable[[Server | None], t.Any] | None = None + needs_preboot: bool = False + preflight: bool = True + + +IMPLS: dict[str, Impl] = { + "classic": Impl("classic", "classic"), + "subprocess": Impl( + "subprocess", "builder", lambda s: SubprocessEngine.for_server(s) + ), + "control_mode": Impl( + "control_mode", "builder", lambda s: ControlModeEngine.for_server(s) + ), + "imsg": Impl("imsg", "builder", lambda s: ImsgForServer(s), needs_preboot=True), + "concrete": Impl( + "concrete", "offline", lambda s: ConcreteEngine(), preflight=False + ), + "pipelined": Impl( + "pipelined", "pipelined", lambda s: ControlModeEngine.for_server(s) + ), +} + + +def do_build( + impl: Impl, server: Server | None, engine: t.Any, name: str, w: int, p: int +) -> None: + """Dispatch one build of *w* x *p* to the right implementation path.""" + if impl.kind == "classic": + build_classic(server, name, w, p) # type: ignore[arg-type] + elif impl.kind == "pipelined": + build_pipelined(engine, name, w, p) + else: # builder / offline + spec(name, w, p).build(engine, preflight=impl.preflight) + + +def wait_ready( + server: Server, name: str, timeout: float = 2.0, interval: float = 0.015 +) -> None: + """Poll each pane until its shell has drawn something (a prompt) or timeout. + + Models the classic ``_wait_for_pane_ready`` cost -- the shell-readiness wait + that inflates 'realistic' build times. Engine-agnostic (reads via subprocess). + """ + ids = [ + x + for x in server.cmd("list-panes", "-s", "-t", name, "-F", "#{pane_id}").stdout + if x + ] + pending = set(ids) + deadline = time.monotonic() + timeout + while pending and time.monotonic() < deadline: + for pid in list(pending): + cap = server.cmd("capture-pane", "-p", "-t", pid).stdout + if any(line.strip() for line in cap): + pending.discard(pid) + if pending: + time.sleep(interval) + + +def run_cell( + impl: Impl, wins: int, panes: int, wait: bool, runs: int, warmup: int +) -> list[float]: + """Return per-build wall times (ms), in-process, with session cleanup.""" + if impl.kind == "offline": + engine = impl.make_engine(None) # type: ignore[misc] + for _ in range(warmup): + spec(uniq(), wins, panes).build(engine, preflight=False) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + spec(name, wins, panes).build(engine, preflight=False) + samples.append((time.perf_counter() - t0) * 1000) + return samples + + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + engine = impl.make_engine(server) if impl.make_engine else None + try: + for _ in range(warmup): + name = uniq() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + server.cmd("kill-session", "-t", name) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + samples.append((time.perf_counter() - t0) * 1000) + server.cmd("kill-session", "-t", name) # untimed cleanup -> no accumulation + return samples + finally: + with contextlib.suppress(Exception): + server.kill() + + +# --------------------------------------------------------------------------- # +# Stats (nearest-rank percentiles, like agentgrep's benchmark) # +# --------------------------------------------------------------------------- # +def percentile(sorted_vals: list[float], pct: float) -> float: + """Nearest-rank percentile of a pre-sorted sequence.""" + if not sorted_vals: + return float("nan") + rank = max(1, math.ceil(pct / 100.0 * len(sorted_vals))) + return sorted_vals[min(rank, len(sorted_vals)) - 1] + + +def summarize(samples: list[float]) -> dict[str, float]: + """Return min/avg/median/p90/p95/p99/max (and n) for *samples*.""" + s = sorted(samples) + return { + "n": float(len(s)), + "min": s[0], + "avg": statistics.fmean(s), + "median": statistics.median(s), + "p90": percentile(s, 90), + "p95": percentile(s, 95), + "p99": percentile(s, 99), + "max": s[-1], + } + + +# --------------------------------------------------------------------------- # +# CLI # +# --------------------------------------------------------------------------- # +app = typer.Typer(add_completion=False, help=__doc__) + + +@app.command() +def run( + shapes: str = typer.Option("1x1,1x4,3x3,5x4,8x4", help="comma WxP shapes"), + engines: str = typer.Option( + "classic,subprocess,control_mode,imsg,concrete,pipelined", + help="comma engine names", + ), + wait: bool = typer.Option(False, help="ALSO measure with shell-readiness wait"), + runs: int = typer.Option(20, help="timed builds per cell"), + warmup: int = typer.Option(3, help="warmup builds per cell"), + json_out: str = typer.Option("", help="write full JSON results here"), +) -> None: + """In-process build-only benchmark grid (the clean signal).""" + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + engine_list = [e for e in engines.split(",") if e in IMPLS] + wait_modes = [False, True] if wait else [False] + results: list[dict[str, t.Any]] = [] + + for wm in wait_modes: + for wins, panes in shape_list: + table = rich.table.Table( + title=f"[bold]{wins} win x {panes} pane ({wins * panes} panes)" + f"{' [wait]' if wm else ''} -- in-process build ms[/bold]" + ) + table.add_column("engine", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("vs classic", justify="right", style="green") + base_median = None + for name in engine_list: + impl = IMPLS[name] + if impl.kind == "offline" and wm: + continue # no real panes to wait on + samples = run_cell(impl, wins, panes, wm, runs, warmup) + st = summarize(samples) + if name == "classic": + base_median = st["median"] + speed = ( + f"{base_median / st['median']:.1f}x" + if base_median and st["median"] + else "-" + ) + table.add_row( + name, + f"{int(st['n'])}", + *[f"{st[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + results.append( + { + "engine": name, + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "wait": wm, + "samples_ms": samples, + **{f"{k}_ms": st[k] for k in STAT_LABELS}, + } + ) + console.print(table) + console.print() + + if json_out: + pathlib.Path(json_out).write_text(json.dumps(results, indent=2)) + console.print(f"[dim]wrote {json_out}[/dim]") + + +@app.command() +def cell(engine: str, shape: str, wait: bool = typer.Option(False)) -> None: + """Build ONE workspace of *shape* with *engine* (isolated). For hyperfine.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + if impl.kind == "offline": + spec(uniq(), wins, panes).build(impl.make_engine(None), preflight=False) # type: ignore[misc] + return + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if wait: + wait_ready(server, name) + finally: + with contextlib.suppress(Exception): + server.kill() + + +@app.command() +def profile( + engine: str = typer.Option("control_mode"), + shape: str = typer.Option("8x4"), + builds: int = typer.Option(5), + top: int = typer.Option(18), +) -> None: + """Profile *builds* builds of *shape* with *engine*; print slowest by cumtime.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + server = None if impl.kind == "offline" else new_server() + if server is not None and impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + warm = uniq() + do_build(impl, server, eng, warm, wins, panes) # warmup + if server is not None: + server.cmd("kill-session", "-t", warm) + pr = cProfile.Profile() + pr.enable() + for _ in range(builds): + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if server is not None: + server.cmd("kill-session", "-t", name) + pr.disable() + buf = io.StringIO() + pstats.Stats(pr, stream=buf).sort_stats("cumulative").print_stats(top) + console.print(f"[bold]profile: {engine} {shape} x{builds}[/bold]") + console.print(buf.getvalue()) + finally: + if server is not None: + with contextlib.suppress(Exception): + server.kill() + + +if __name__ == "__main__": + app() From 2f3e5b2965edb1150de13ecd93b7f85d0a23b87e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 11:13:24 -0500 Subject: [PATCH 151/223] Scripts(docs[bench]): Add engine benchmark results why: Version the measured build cost across engines (and the pipelining prototype) alongside the harness so the numbers are reproducible and reviewable. what: - Add scripts/bench-results/ with RESULTS.md (analysis: the engine grid, with/without shell-readiness wait, profile, and the ~79x reconciliation) plus grid.json / wait.json (raw per-run data from scripts/bench_engines.py). --- scripts/bench-results/RESULTS.md | 83 +++ scripts/bench-results/grid.json | 1082 ++++++++++++++++++++++++++++++ scripts/bench-results/wait.json | 314 +++++++++ 3 files changed, 1479 insertions(+) create mode 100644 scripts/bench-results/RESULTS.md create mode 100644 scripts/bench-results/grid.json create mode 100644 scripts/bench-results/wait.json diff --git a/scripts/bench-results/RESULTS.md b/scripts/bench-results/RESULTS.md new file mode 100644 index 000000000..f96debdeb --- /dev/null +++ b/scripts/bench-results/RESULTS.md @@ -0,0 +1,83 @@ +# libtmux engine build-benchmark — results + +Produced by `scripts/bench_engines.py` (a hermetic PEP 723 grid runner) plus a +one-off hyperfine end-to-end run. All builds are isolated: per-run sockets under +a throwaway dir, `TMUX` unset, servers killed on exit — the default tmux server +is never contacted. Reproduce with: + +```console +$ uv run scripts/bench_engines.py run +$ uv run scripts/bench_engines.py run --engines classic,control_mode,pipelined --wait +$ uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4 +``` + +Raw data: `grid.json` (no-wait grid), `wait.json` (wait comparison). + +## Engine grid — in-process build, median ms (xN vs classic), 20 runs + +Shape = `windows x panes-per-window`. Structural builds (no shell-readiness wait). + +| engine | 1x1 | 1x4 | 3x3 | 5x4 | 8x4 | +|---|--:|--:|--:|--:|--:| +| classic (Server/Session/Window/Pane) | 22.0 | 169.4 | 452.5 | 1442.2 | 3497.2 | +| builder / subprocess | 23.0 | 42.8 | 86.9 | 246.7 | 428.8 (8x) | +| builder / imsg | 20.6 | 31.1 | 62.6 | 153.4 | 262.0 (13x) | +| builder / control_mode | 2.5 | 9.4 | 26.5 | 103.3 | 166.7 (21x) | +| **pipelined (prototype)** | **1.4** | **7.9** | **20.2** | **65.3** | **115.7 (30x)** | +| concrete (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | + +Full percentiles at 8x4 (ms): + +| engine | min | avg | median | p90 | p95 | p99 | max | +|---|--:|--:|--:|--:|--:|--:|--:| +| classic | 2192 | 3404 | 3497 | 3931 | 4077 | 4432 | 4432 | +| subprocess | 358 | 426 | 429 | 479 | 481 | 487 | 487 | +| imsg | 222 | 283 | 262 | 342 | 421 | 455 | 455 | +| control_mode | 118 | 180 | 167 | 215 | 216 | 398 | 398 | +| pipelined | 97 | 123 | 116 | 156 | 165 | 194 | 194 | +| concrete | 1 | 2 | 2 | 2 | 2 | 2 | 2 | + +Reads: + +- **control_mode** (one persistent `tmux -C`, no per-op fork) is the fastest + shipped engine: 21x classic at 32 panes. +- **imsg** (AF_UNIX one-shot per call) sits between subprocess and control_mode; + its per-call handshake makes tiny builds no faster than classic. +- **pipelined** (prototype: batch independent creates into ~3 `run_batch` + round-trips instead of ~34) is fastest overall, ~1.4x over control_mode. Not + the 11x the round-trip count implies, because the build is **tmux-server-bound** + (one shell fork per pane), not round-trip-bound. `concrete` (offline, 1.5 ms) + is the Python floor: the plan/compile layer is negligible; the time is tmux. + +## With vs without shell-readiness wait + +`wait.json`, 10 runs. "wait" polls each pane until its shell has drawn a prompt. + +| shape | engine | nowait median | wait median | wait penalty | speedup vs classic | +|---|---|--:|--:|--:|--:| +| 1x4 | classic | 217.6 | 1134.5 | 5.2x | 1.0x | +| 1x4 | control_mode | 9.4 | 865.2 | 92x | 23.1x -> 1.3x | +| 1x4 | pipelined | 9.0 | 1004.3 | 112x | 24.3x -> 1.1x | +| 5x4 | classic | 1365.6 | 3252.7 | 2.4x | 1.0x | +| 5x4 | control_mode | 73.1 | 2194.5 | 30x | 18.7x -> 1.5x | +| 5x4 | pipelined | 66.6 | 2123.3 | 32x | 20.5x -> 1.5x | + +**The engine win only exists when nobody waits for shells.** Shell startup +(~0.8-2.1 s) dominates a fast build (30-112x penalty) but barely moves the slow +classic path (2-5x), so the ~20x engine advantage collapses to ~1.5x once both +sides wait. Comparing a classic-that-waits against a builder-that-doesn't is +apples-to-oranges (this is why an earlier ad-hoc run reported ~79x). + +## Profile — where a control_mode build spends time (32 panes x5) + +~200 ms/build; **68% is `select.epoll.poll`** in `control_mode._read_blocks` — +one round-trip per created id (each new window/pane id read back before the next +op targets it). Round-trip-latency bound, not CPU/Python bound. + +## Whole-process wall time (hyperfine, SubprocessEngine, end-to-end) + +For reference, whole-script wall time (Python start + import + build + teardown) +on `SubprocessEngine`, 50 runs: classic simple 210 ms / large 6764 ms; builder +simple 453 ms / large 1692 ms. End-to-end on the *slowest* engine understates the +builder (startup dwarfs a 3 ms build) — the in-process grid above is the clean +signal. Prefer `control_mode` and in-process timing to measure build cost. diff --git a/scripts/bench-results/grid.json b/scripts/bench-results/grid.json new file mode 100644 index 000000000..8df01a81e --- /dev/null +++ b/scripts/bench-results/grid.json @@ -0,0 +1,1082 @@ +[ + { + "engine": "classic", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.435490999370813, + 21.44604700151831, + 19.487711018882692, + 22.916321991942823, + 26.080587063916028, + 20.377128035761416, + 19.85792105551809, + 21.783681004308164, + 27.05869299825281, + 22.205271059647202, + 20.67994710523635, + 26.499966043047607, + 24.94822407606989, + 30.270048999227583, + 29.39265000168234, + 21.638029953464866, + 20.480802981182933, + 20.635419990867376, + 26.2573619838804, + 19.851638935506344 + ], + "n_ms": 20.0, + "min_ms": 19.487711018882692, + "avg_ms": 23.365147114964202, + "median_ms": 21.994476031977683, + "p90_ms": 27.05869299825281, + "p95_ms": 29.39265000168234, + "p99_ms": 30.270048999227583, + "max_ms": 30.270048999227583 + }, + { + "engine": "subprocess", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 22.950448095798492, + 23.71105900965631, + 27.512941975146532, + 24.13841593079269, + 22.9648700915277, + 23.848011973313987, + 25.90626000892371, + 20.328919985331595, + 21.03408903349191, + 21.60188800189644, + 26.37235203292221, + 22.638716967776418, + 20.477519021369517, + 19.63279501069337, + 28.77276297658682, + 25.713130016811192, + 20.554474089294672, + 20.25220892392099, + 28.945287107490003, + 22.18223095405847 + ], + "n_ms": 20.0, + "min_ms": 19.63279501069337, + "avg_ms": 23.47691906034015, + "median_ms": 22.957659093663096, + "p90_ms": 27.512941975146532, + "p95_ms": 28.77276297658682, + "p99_ms": 28.945287107490003, + "max_ms": 28.945287107490003 + }, + { + "engine": "control_mode", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 2.3771480191498995, + 2.6499099330976605, + 2.8321900172159076, + 2.5614179903641343, + 2.9272950487211347, + 2.5065389927476645, + 2.832969999872148, + 2.916119061410427, + 2.130561973899603, + 2.0278169540688396, + 2.620737999677658, + 2.21067201346159, + 1.859560958109796, + 2.2114990279078484, + 2.182059921324253, + 2.0884659606963396, + 2.447605016641319, + 4.296073107980192, + 4.0978980250656605, + 3.2866770634427667 + ], + "n_ms": 20.0, + "min_ms": 1.859560958109796, + "avg_ms": 2.653160854242742, + "median_ms": 2.5339784915558994, + "p90_ms": 3.2866770634427667, + "p95_ms": 4.0978980250656605, + "p99_ms": 4.296073107980192, + "max_ms": 4.296073107980192 + }, + { + "engine": "imsg", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.242659961804748, + 23.58605689369142, + 21.349252085201442, + 22.916006040759385, + 18.253559013828635, + 18.885195022448897, + 35.018087015487254, + 37.03230095561594, + 36.96872899308801, + 36.74224903807044, + 18.353665014728904, + 18.583990982733667, + 17.208026023581624, + 24.591958965174854, + 19.304446992464364, + 18.249089014716446, + 18.050470971502364, + 22.29922788683325, + 19.827933982014656, + 17.557888058945537 + ], + "n_ms": 20.0, + "min_ms": 17.208026023581624, + "avg_ms": 23.50103964563459, + "median_ms": 20.58859303360805, + "p90_ms": 36.74224903807044, + "p95_ms": 36.96872899308801, + "p99_ms": 37.03230095561594, + "max_ms": 37.03230095561594 + }, + { + "engine": "concrete", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 0.05781592335551977, + 0.05536503158509731, + 0.08927390445023775, + 0.06635801400989294, + 0.05585001781582832, + 0.05495199002325535, + 0.053521012887358665, + 0.05605991464108229, + 0.05437701474875212, + 0.052780029363930225, + 0.05164195317775011, + 0.0517780426889658, + 0.0520970206707716, + 0.05274603608995676, + 0.05097396206110716, + 0.05048594903200865, + 0.05105405580252409, + 0.051804003305733204, + 0.05062599666416645, + 0.0513358972966671 + ], + "n_ms": 20.0, + "min_ms": 0.05048594903200865, + "avg_ms": 0.05554478848353028, + "median_ms": 0.05276303272694349, + "p90_ms": 0.05781592335551977, + "p95_ms": 0.06635801400989294, + "p99_ms": 0.08927390445023775, + "max_ms": 0.08927390445023775 + }, + { + "engine": "pipelined", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 1.272339024581015, + 1.423096051439643, + 2.0246210042387247, + 2.4055520771071315, + 2.593372017145157, + 1.708880066871643, + 1.5237980987876654, + 1.258800970390439, + 1.4068959280848503, + 1.528986031189561, + 1.2518159346655011, + 1.1928770691156387, + 1.2443959712982178, + 1.61149597261101, + 1.3652080669999123, + 1.1852029711008072, + 1.2318679364398122, + 1.9605309935286641, + 2.02260899823159, + 1.3686279999092221 + ], + "n_ms": 20.0, + "min_ms": 1.1852029711008072, + "avg_ms": 1.5790486591868103, + "median_ms": 1.4149959897622466, + "p90_ms": 2.0246210042387247, + "p95_ms": 2.4055520771071315, + "p99_ms": 2.593372017145157, + "max_ms": 2.593372017145157 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 153.5736940568313, + 152.53363398369402, + 176.28063401207328, + 162.175236037001, + 175.2850729972124, + 169.6498990058899, + 160.512474947609, + 175.7287960499525, + 156.43915405962616, + 160.1339690387249, + 207.24134298507124, + 189.16924006771296, + 194.17230098042637, + 170.98306701518595, + 161.9495979975909, + 175.3947560209781, + 170.09779904037714, + 154.71308294218034, + 169.2105890251696, + 141.2136929575354 + ], + "n_ms": 20.0, + "min_ms": 141.2136929575354, + "avg_ms": 168.82290166104212, + "median_ms": 169.43024401552975, + "p90_ms": 189.16924006771296, + "p95_ms": 194.17230098042637, + "p99_ms": 207.24134298507124, + "max_ms": 207.24134298507124 + }, + { + "engine": "subprocess", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 38.20429800543934, + 34.88947299774736, + 56.08579399995506, + 35.82027601078153, + 36.9337199954316, + 35.59582296293229, + 46.69300094246864, + 39.19104894157499, + 37.6851549372077, + 35.862258984707296, + 30.545516056008637, + 41.76524991635233, + 50.84225791506469, + 50.91948399785906, + 55.70168199483305, + 49.489257973618805, + 43.84331707842648, + 59.19129599351436, + 52.224029088392854, + 54.89515699446201 + ], + "n_ms": 20.0, + "min_ms": 30.545516056008637, + "avg_ms": 44.318904739338905, + "median_ms": 42.804283497389406, + "p90_ms": 55.70168199483305, + "p95_ms": 56.08579399995506, + "p99_ms": 59.19129599351436, + "max_ms": 59.19129599351436 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 7.563800900243223, + 8.978422963991761, + 8.618877036496997, + 9.57214506343007, + 10.934944031760097, + 12.400830979458988, + 11.549205984920263, + 7.952383020892739, + 9.504236048087478, + 8.437798009254038, + 7.7332829823717475, + 9.446645970456302, + 9.59436094854027, + 8.322115987539291, + 9.378890972584486, + 8.756348979659379, + 9.824263979680836, + 7.038456969894469, + 10.727863991633058, + 11.08790806028992 + ], + "n_ms": 20.0, + "min_ms": 7.038456969894469, + "avg_ms": 9.37113914405927, + "median_ms": 9.412768471520394, + "p90_ms": 11.08790806028992, + "p95_ms": 11.549205984920263, + "p99_ms": 12.400830979458988, + "max_ms": 12.400830979458988 + }, + { + "engine": "imsg", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 36.68499703053385, + 34.726232988759875, + 24.83677805867046, + 26.792447897605598, + 36.66620596777648, + 28.368790983222425, + 27.89685397874564, + 37.136508035473526, + 34.588526003062725, + 31.007860088720918, + 26.524171931669116, + 33.32362789660692, + 44.18608907144517, + 39.438307052478194, + 28.73879298567772, + 27.788623003289104, + 30.046261032111943, + 31.244902056641877, + 33.68811297696084, + 28.78861501812935 + ], + "n_ms": 20.0, + "min_ms": 24.83677805867046, + "avg_ms": 32.123635202879086, + "median_ms": 31.126381072681397, + "p90_ms": 37.136508035473526, + "p95_ms": 39.438307052478194, + "p99_ms": 44.18608907144517, + "max_ms": 44.18608907144517 + }, + { + "engine": "concrete", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 0.13607402797788382, + 0.23710704408586025, + 0.14775199815630913, + 0.13310997746884823, + 0.12774800416082144, + 0.12735999189317226, + 0.1251481007784605, + 0.12269604485481977, + 0.12107205111533403, + 0.11823198292404413, + 0.27573294937610626, + 0.2059279941022396, + 0.1430619740858674, + 0.1845939550548792, + 0.13147201389074326, + 0.13140100054442883, + 0.125426915474236, + 0.1242499565705657, + 0.12552295811474323, + 0.12560293544083834 + ], + "n_ms": 20.0, + "min_ms": 0.11823198292404413, + "avg_ms": 0.14846459380351007, + "median_ms": 0.12957450235262513, + "p90_ms": 0.2059279941022396, + "p95_ms": 0.23710704408586025, + "p99_ms": 0.27573294937610626, + "max_ms": 0.27573294937610626 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.6258920123800635, + 9.559032041579485, + 9.58340906072408, + 5.963396979495883, + 8.836780907586217, + 10.417644982226193, + 7.979689980857074, + 8.669061004184186, + 6.5402110340073705, + 7.1102980291470885, + 6.61726703401655, + 7.768513984046876, + 8.26644105836749, + 6.927274982444942, + 6.618922925554216, + 6.4139129826799035, + 7.574769086204469, + 9.094446897506714, + 8.622737019322813, + 9.986574063077569 + ], + "n_ms": 20.0, + "min_ms": 5.963396979495883, + "avg_ms": 7.958813803270459, + "median_ms": 7.874101982451975, + "p90_ms": 9.58340906072408, + "p95_ms": 9.986574063077569, + "p99_ms": 10.417644982226193, + "max_ms": 10.417644982226193 + }, + { + "engine": "classic", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 463.1088289897889, + 403.2544719520956, + 459.22533597331494, + 505.975084961392, + 438.8479490298778, + 482.3058929760009, + 413.75007992610335, + 413.2016849471256, + 444.53056796919554, + 483.2421139581129, + 468.3551120106131, + 470.5149090150371, + 416.62799497134984, + 454.917594906874, + 409.0973420534283, + 427.1308289607987, + 460.38287493865937, + 450.0137569848448, + 479.48227205779403, + 442.03562603797764 + ], + "n_ms": 20.0, + "min_ms": 403.2544719520956, + "avg_ms": 449.3000161310192, + "median_ms": 452.4656759458594, + "p90_ms": 482.3058929760009, + "p95_ms": 483.2421139581129, + "p99_ms": 505.975084961392, + "max_ms": 505.975084961392 + }, + { + "engine": "subprocess", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 96.40262997709215, + 107.8126790234819, + 102.82182600349188, + 95.2509690541774, + 93.2349389186129, + 86.750422953628, + 89.36347393319011, + 89.38358607701957, + 113.29700902570039, + 82.9845640109852, + 90.94950300641358, + 83.90699897427112, + 81.64807397406548, + 87.03590603545308, + 73.54520098306239, + 82.44245196692646, + 72.46743002906442, + 82.35985110513866, + 79.32134799193591, + 83.34585605189204 + ], + "n_ms": 20.0, + "min_ms": 72.46743002906442, + "avg_ms": 88.71623595478013, + "median_ms": 86.89316449454054, + "p90_ms": 102.82182600349188, + "p95_ms": 107.8126790234819, + "p99_ms": 113.29700902570039, + "max_ms": 113.29700902570039 + }, + { + "engine": "control_mode", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 30.5233730468899, + 24.231599061749876, + 25.235411943867803, + 27.411659015342593, + 24.05042899772525, + 25.479506002739072, + 28.722655959427357, + 22.07455795723945, + 22.580575081519783, + 29.86845897976309, + 27.496899012476206, + 36.62381402682513, + 37.449890980497, + 26.981694041751325, + 24.941370938904583, + 25.788024999201298, + 30.847082030959427, + 26.0819629766047, + 23.499268922023475, + 27.957514976151288 + ], + "n_ms": 20.0, + "min_ms": 22.07455795723945, + "avg_ms": 27.39228744758293, + "median_ms": 26.531828509178013, + "p90_ms": 30.847082030959427, + "p95_ms": 36.62381402682513, + "p99_ms": 37.449890980497, + "max_ms": 37.449890980497 + }, + { + "engine": "imsg", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 73.45083705149591, + 70.16680098604411, + 78.33038899116218, + 74.66395699884742, + 56.962854927405715, + 57.84656805917621, + 64.33053908403963, + 54.64023910462856, + 58.12385701574385, + 62.07921903114766, + 55.78872701153159, + 59.88113197963685, + 63.08747094590217, + 69.321816903539, + 56.98866699822247, + 71.06748304795474, + 59.83901908621192, + 61.00640504155308, + 77.51034398097545, + 64.7579530486837 + ], + "n_ms": 20.0, + "min_ms": 54.64023910462856, + "avg_ms": 64.49221396469511, + "median_ms": 62.583344988524914, + "p90_ms": 74.66395699884742, + "p95_ms": 77.51034398097545, + "p99_ms": 78.33038899116218, + "max_ms": 78.33038899116218 + }, + { + "engine": "concrete", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 0.2903119893744588, + 0.28088700491935015, + 0.3294319612905383, + 0.2774670720100403, + 0.28050492983311415, + 0.29172003269195557, + 0.2925050212070346, + 0.4807590739801526, + 0.30473608057945967, + 0.33838499803096056, + 0.2801839727908373, + 0.26983011048287153, + 0.31153589952737093, + 0.30686904210597277, + 0.26840297505259514, + 0.2645669737830758, + 0.3714329795911908, + 0.28299796395003796, + 0.2705819206312299, + 0.28041808400303125 + ], + "n_ms": 20.0, + "min_ms": 0.2645669737830758, + "avg_ms": 0.3036764042917639, + "median_ms": 0.2866549766622484, + "p90_ms": 0.33838499803096056, + "p95_ms": 0.3714329795911908, + "p99_ms": 0.4807590739801526, + "max_ms": 0.4807590739801526 + }, + { + "engine": "pipelined", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 16.53240597806871, + 15.442625037394464, + 19.34879703912884, + 22.86289702169597, + 18.47324299160391, + 18.982085050083697, + 24.889598018489778, + 19.762809039093554, + 17.981484066694975, + 19.241684931330383, + 20.058405003510416, + 20.32635302748531, + 19.626682973466814, + 21.44766692072153, + 25.036148028448224, + 22.131431964226067, + 27.264841948635876, + 27.298758970573545, + 25.995625066570938, + 28.35541300009936 + ], + "n_ms": 20.0, + "min_ms": 15.442625037394464, + "avg_ms": 21.552947803866118, + "median_ms": 20.192379015497863, + "p90_ms": 27.264841948635876, + "p95_ms": 27.298758970573545, + "p99_ms": 28.35541300009936, + "max_ms": 28.35541300009936 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1281.397273996845, + 1210.668591898866, + 1242.258501937613, + 1174.9852310167626, + 1330.7410050183535, + 1521.1127869552001, + 1366.0107220057398, + 1602.0200490020216, + 1404.2648519389331, + 1466.6325360303745, + 1482.5694229220971, + 1441.553378943354, + 1442.840927047655, + 1607.132775010541, + 1580.0651350291446, + 1381.9000310031697, + 1454.8676230479032, + 1629.573607002385, + 1485.2986589539796, + 1375.63347897958 + ], + "n_ms": 20.0, + "min_ms": 1174.9852310167626, + "avg_ms": 1424.076329387026, + "median_ms": 1442.1971529955044, + "p90_ms": 1602.0200490020216, + "p95_ms": 1607.132775010541, + "p99_ms": 1629.573607002385, + "max_ms": 1629.573607002385 + }, + { + "engine": "subprocess", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 230.99103895947337, + 199.28296492435038, + 270.25563502684236, + 317.19566497486085, + 455.45686106197536, + 497.62218398973346, + 347.6630869554356, + 265.15684905461967, + 224.24249001778662, + 214.0263100154698, + 215.80458094831556, + 252.42189702112228, + 245.46299397479743, + 257.7294419752434, + 194.6850820677355, + 246.80601397994906, + 246.52875203173608, + 262.4232630478218, + 235.30278506223112, + 216.59297600854188 + ], + "n_ms": 20.0, + "min_ms": 194.6850820677355, + "avg_ms": 269.7825435549021, + "median_ms": 246.66738300584257, + "p90_ms": 347.6630869554356, + "p95_ms": 455.45686106197536, + "p99_ms": 497.62218398973346, + "max_ms": 497.62218398973346 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.78025595284998, + 79.53090802766383, + 236.67864105664194, + 233.00717200618237, + 328.0731838895008, + 131.67984399478883, + 164.9903169600293, + 336.2570349127054, + 328.47389997914433, + 113.03125496488065, + 83.44777394086123, + 72.73705000989139, + 70.41866099461913, + 179.36235608067364, + 102.63979400042444, + 71.07233500573784, + 73.72917397879064, + 70.85887296125293, + 104.05752004589885, + 94.78877391666174 + ], + "n_ms": 20.0, + "min_ms": 70.41866099461913, + "avg_ms": 147.53074113395996, + "median_ms": 103.34865702316165, + "p90_ms": 328.0731838895008, + "p95_ms": 328.47389997914433, + "p99_ms": 336.2570349127054, + "max_ms": 336.2570349127054 + }, + { + "engine": "imsg", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 134.2119559412822, + 210.86893905885518, + 148.33857398480177, + 138.0466379923746, + 154.90469709038734, + 130.4093300132081, + 151.83229907415807, + 218.34416908677667, + 124.5981550309807, + 128.86274000629783, + 159.59694795310497, + 145.80014697276056, + 135.36079099867493, + 260.35256509203464, + 163.294667028822, + 218.46147894393653, + 177.09315801039338, + 157.00659702997655, + 137.62785703875124, + 177.16083896812052 + ], + "n_ms": 20.0, + "min_ms": 124.5981550309807, + "avg_ms": 163.6086272657849, + "median_ms": 153.3684980822727, + "p90_ms": 218.34416908677667, + "p95_ms": 218.46147894393653, + "p99_ms": 260.35256509203464, + "max_ms": 260.35256509203464 + }, + { + "engine": "concrete", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1.1191670782864094, + 0.9929570369422436, + 0.8589229546487331, + 0.7576720090582967, + 1.3178159715607762, + 1.1864739935845137, + 1.3520210050046444, + 1.2667239643633366, + 1.0587309952825308, + 1.7452990869060159, + 1.4916330110281706, + 3.988807089626789, + 2.0200000144541264, + 1.7959449905902147, + 1.871323911473155, + 1.5445369062945247, + 1.4150440692901611, + 1.2863259762525558, + 0.6520430324599147, + 0.6350380135700107 + ], + "n_ms": 20.0, + "min_ms": 0.6350380135700107, + "avg_ms": 1.4178240555338562, + "median_ms": 1.302070973906666, + "p90_ms": 1.871323911473155, + "p95_ms": 2.0200000144541264, + "p99_ms": 3.988807089626789, + "max_ms": 3.988807089626789 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 113.15120500512421, + 54.02535106986761, + 64.92527201771736, + 78.20391294080764, + 57.41404299624264, + 70.83705102559179, + 92.94041607063264, + 56.410389952361584, + 69.41384205128998, + 65.76590496115386, + 108.70656406041235, + 64.65335004031658, + 75.56975504849106, + 68.74816899653524, + 75.74488001409918, + 60.71585509926081, + 57.00136907398701, + 61.06731400359422, + 58.447972987778485, + 59.52235695440322 + ], + "n_ms": 20.0, + "min_ms": 54.02535106986761, + "avg_ms": 70.66324871848337, + "median_ms": 65.34558848943561, + "p90_ms": 92.94041607063264, + "p95_ms": 108.70656406041235, + "p99_ms": 113.15120500512421, + "max_ms": 113.15120500512421 + }, + { + "engine": "classic", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 3666.3628419628367, + 3372.8903520386666, + 3848.983636009507, + 3508.1395419547334, + 3486.258820979856, + 3584.1793949948624, + 3485.946947010234, + 3771.2144720135257, + 3931.1889680102468, + 4432.214422966354, + 4076.763738063164, + 2764.4857480190694, + 3215.3420510003343, + 3295.630355947651, + 3329.8535760259256, + 3519.156881957315, + 3853.867839090526, + 2536.271504010074, + 2208.0091719981283, + 2192.4075819551945 + ], + "n_ms": 20.0, + "min_ms": 2192.4075819551945, + "avg_ms": 3403.95839230041, + "median_ms": 3497.1991814672947, + "p90_ms": 3931.1889680102468, + "p95_ms": 4076.763738063164, + "p99_ms": 4432.214422966354, + "max_ms": 4432.214422966354 + }, + { + "engine": "subprocess", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 392.0094169443473, + 399.5577469468117, + 461.8747670901939, + 463.75522401649505, + 403.79654499702156, + 478.78038801718503, + 486.85317998752, + 401.86715801246464, + 385.10203699115664, + 421.61177797243, + 463.4238800499588, + 357.96659102197737, + 435.90391403995454, + 470.3146460233256, + 480.929414043203, + 438.19006194826216, + 395.19416098482907, + 373.0431740405038, + 367.8618990816176, + 445.61960094142705 + ], + "n_ms": 20.0, + "min_ms": 357.96659102197737, + "avg_ms": 426.18277915753424, + "median_ms": 428.75784600619227, + "p90_ms": 478.78038801718503, + "p95_ms": 480.929414043203, + "p99_ms": 486.85317998752, + "max_ms": 486.85317998752 + }, + { + "engine": "control_mode", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 152.8084089513868, + 117.58937302511185, + 195.70027699228376, + 138.3822859497741, + 397.9049799963832, + 215.567508013919, + 138.99186393246055, + 188.69415298104286, + 189.87572100013494, + 173.7240820657462, + 158.34677510429174, + 215.0000180117786, + 194.2786539439112, + 176.19880801066756, + 153.5388040356338, + 159.58266996312886, + 143.19772401358932, + 153.82824302650988, + 178.53682104032487, + 147.99589000176638 + ], + "n_ms": 20.0, + "min_ms": 117.58937302511185, + "avg_ms": 179.48715300299227, + "median_ms": 166.65337601443753, + "p90_ms": 215.0000180117786, + "p95_ms": 215.567508013919, + "p99_ms": 397.9049799963832, + "max_ms": 397.9049799963832 + }, + { + "engine": "imsg", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 454.6383459819481, + 259.375739027746, + 249.91479504387826, + 420.9431590279564, + 222.0811820589006, + 264.5984790287912, + 231.42871004529297, + 333.20740202907473, + 254.01110399980098, + 341.4921569637954, + 272.3997130524367, + 239.1474029282108, + 242.0606450177729, + 231.84302891604602, + 285.94926302321255, + 250.6428079213947, + 266.55483595095575, + 276.24492603354156, + 326.5154130058363, + 226.46799904759973 + ], + "n_ms": 20.0, + "min_ms": 222.0811820589006, + "avg_ms": 282.4758554052096, + "median_ms": 261.9871090282686, + "p90_ms": 341.4921569637954, + "p95_ms": 420.9431590279564, + "p99_ms": 454.6383459819481, + "max_ms": 454.6383459819481 + }, + { + "engine": "concrete", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 1.0853420244529843, + 0.9852750226855278, + 0.8930320618674159, + 1.6033079009503126, + 1.4309020480141044, + 1.3377750292420387, + 2.1038519917055964, + 1.9198539666831493, + 1.9366199849173427, + 2.0881620002910495, + 1.9528960110619664, + 2.314181998372078, + 1.6242529964074492, + 1.7207990167662501, + 1.67950801551342, + 1.1982190189883113, + 1.0953579330816865, + 1.4674999983981252, + 1.091616926714778, + 1.1784050147980452 + ], + "n_ms": 20.0, + "min_ms": 0.8930320618674159, + "avg_ms": 1.5353429480455816, + "median_ms": 1.535403949674219, + "p90_ms": 2.0881620002910495, + "p95_ms": 2.1038519917055964, + "p99_ms": 2.314181998372078, + "max_ms": 2.314181998372078 + }, + { + "engine": "pipelined", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 100.61924997717142, + 96.57052101101726, + 99.77939596865326, + 117.95211094431579, + 113.54388005565852, + 98.9359940867871, + 124.54905500635505, + 101.82711097877473, + 156.30723407957703, + 125.18065399490297, + 110.4188309982419, + 133.29723698552698, + 140.21150907501578, + 165.18471902236342, + 121.83465505950153, + 193.51797795388848, + 152.09435508586466, + 102.82438900321722, + 101.34623397607356, + 110.86194100789726 + ], + "n_ms": 20.0, + "min_ms": 96.57052101101726, + "avg_ms": 123.3428527135402, + "median_ms": 115.74799549998716, + "p90_ms": 156.30723407957703, + "p95_ms": 165.18471902236342, + "p99_ms": 193.51797795388848, + "max_ms": 193.51797795388848 + } +] \ No newline at end of file diff --git a/scripts/bench-results/wait.json b/scripts/bench-results/wait.json new file mode 100644 index 000000000..0b138f7c4 --- /dev/null +++ b/scripts/bench-results/wait.json @@ -0,0 +1,314 @@ +[ + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 166.7344990419224, + 212.69031206611544, + 256.8014459684491, + 199.50575497932732, + 261.5824770182371, + 222.48539805877954, + 203.23852205183357, + 242.7966770483181, + 239.08080800902098, + 198.84139497298747 + ], + "n_ms": 10.0, + "min_ms": 166.7344990419224, + "avg_ms": 220.3757289214991, + "median_ms": 217.5878550624475, + "p90_ms": 256.8014459684491, + "p95_ms": 261.5824770182371, + "p99_ms": 261.5824770182371, + "max_ms": 261.5824770182371 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 8.247727062553167, + 10.702441912144423, + 14.183179941028357, + 10.467821964994073, + 8.07721505407244, + 9.324315935373306, + 8.775111986324191, + 9.275623015128076, + 9.485395043157041, + 13.174964929930866 + ], + "n_ms": 10.0, + "min_ms": 8.07721505407244, + "avg_ms": 10.171379684470594, + "median_ms": 9.404855489265174, + "p90_ms": 13.174964929930866, + "p95_ms": 14.183179941028357, + "p99_ms": 14.183179941028357, + "max_ms": 14.183179941028357 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.229061051271856, + 6.618206040002406, + 8.960460894741118, + 11.124748038128018, + 7.881589001044631, + 11.010617017745972, + 8.948027971200645, + 7.985224016010761, + 10.071107069961727, + 15.785112977027893 + ], + "n_ms": 10.0, + "min_ms": 6.229061051271856, + "avg_ms": 9.461415407713503, + "median_ms": 8.954244432970881, + "p90_ms": 11.124748038128018, + "p95_ms": 15.785112977027893, + "p99_ms": 15.785112977027893, + "max_ms": 15.785112977027893 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1330.9594680322334, + 1398.6252510221675, + 1403.3046170370653, + 1252.3579199332744, + 1248.6419779015705, + 1235.2563559543341, + 1639.7459730505943, + 1629.7535400371999, + 1400.728255044669, + 1332.566024037078 + ], + "n_ms": 10.0, + "min_ms": 1235.2563559543341, + "avg_ms": 1387.1939382050186, + "median_ms": 1365.5956375296228, + "p90_ms": 1629.7535400371999, + "p95_ms": 1639.7459730505943, + "p99_ms": 1639.7459730505943, + "max_ms": 1639.7459730505943 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 68.88843094930053, + 78.3604410244152, + 78.01781396847218, + 72.06234894692898, + 72.52011599484831, + 73.72508093249053, + 64.87809692043811, + 69.0728749614209, + 97.45409805327654, + 90.78932600095868 + ], + "n_ms": 10.0, + "min_ms": 64.87809692043811, + "avg_ms": 76.576862775255, + "median_ms": 73.12259846366942, + "p90_ms": 90.78932600095868, + "p95_ms": 97.45409805327654, + "p99_ms": 97.45409805327654, + "max_ms": 97.45409805327654 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.46699501108378, + 58.07583301793784, + 62.140439986251295, + 74.83123405836523, + 57.96935607213527, + 70.67135605029762, + 62.06154392566532, + 72.16213690117002, + 65.47302007675171, + 67.81659903936088 + ], + "n_ms": 10.0, + "min_ms": 57.96935607213527, + "avg_ms": 66.6668514139019, + "median_ms": 66.6448095580563, + "p90_ms": 74.83123405836523, + "p95_ms": 75.46699501108378, + "p99_ms": 75.46699501108378, + "max_ms": 75.46699501108378 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 2271.074099931866, + 1628.9673490682617, + 1180.6232570670545, + 1546.2298300117254, + 1129.7054740134627, + 1003.3381689572707, + 1103.009557002224, + 1139.2105700215325, + 1072.3191909492016, + 910.3259799303487 + ], + "n_ms": 10.0, + "min_ms": 910.3259799303487, + "avg_ms": 1298.4803476952948, + "median_ms": 1134.4580220174976, + "p90_ms": 1628.9673490682617, + "p95_ms": 2271.074099931866, + "p99_ms": 2271.074099931866, + "max_ms": 2271.074099931866 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 771.0779230110347, + 826.4453769661486, + 858.2343220477924, + 749.7300069080666, + 872.2627089591697, + 856.7365389317274, + 970.8761879010126, + 1058.184701949358, + 969.4039679598063, + 1015.9691049484536 + ], + "n_ms": 10.0, + "min_ms": 749.7300069080666, + "avg_ms": 894.892083958257, + "median_ms": 865.248515503481, + "p90_ms": 1015.9691049484536, + "p95_ms": 1058.184701949358, + "p99_ms": 1058.184701949358, + "max_ms": 1058.184701949358 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 924.671886023134, + 957.4840500717983, + 970.9887669887394, + 717.6021320046857, + 1027.1775299916044, + 1078.7920550210401, + 1047.6982130203396, + 1131.4134739805013, + 1078.2063950318843, + 981.4246169989929 + ], + "n_ms": 10.0, + "min_ms": 717.6021320046857, + "avg_ms": 991.545911913272, + "median_ms": 1004.3010734952986, + "p90_ms": 1078.7920550210401, + "p95_ms": 1131.4134739805013, + "p99_ms": 1131.4134739805013, + "max_ms": 1131.4134739805013 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2688.125988934189, + 3421.498993993737, + 2721.125219017267, + 3680.0719080492854, + 3053.872239892371, + 3266.9221319956705, + 3363.687581033446, + 3229.252888006158, + 3476.997076999396, + 3238.5111950570717 + ], + "n_ms": 10.0, + "min_ms": 2688.125988934189, + "avg_ms": 3214.006522297859, + "median_ms": 3252.716663526371, + "p90_ms": 3476.997076999396, + "p95_ms": 3680.0719080492854, + "p99_ms": 3680.0719080492854, + "max_ms": 3680.0719080492854 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2327.0793380215764, + 2169.6944349678233, + 2290.0202609598637, + 2193.4810240054503, + 2250.2999720163643, + 2082.6522540301085, + 2308.945218101144, + 2176.221609930508, + 2195.5926649970934, + 2136.908065993339 + ], + "n_ms": 10.0, + "min_ms": 2082.6522540301085, + "avg_ms": 2213.089484302327, + "median_ms": 2194.536844501272, + "p90_ms": 2308.945218101144, + "p95_ms": 2327.0793380215764, + "p99_ms": 2327.0793380215764, + "max_ms": 2327.0793380215764 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2107.4311519041657, + 2110.2887169690803, + 2249.8882319778204, + 2227.454266976565, + 2197.3950610263273, + 2126.2204200029373, + 2120.3758959891275, + 2134.5664139371365, + 2097.358933999203, + 2115.1353849563748 + ], + "n_ms": 10.0, + "min_ms": 2097.358933999203, + "avg_ms": 2148.611447773874, + "median_ms": 2123.2981579960324, + "p90_ms": 2227.454266976565, + "p95_ms": 2249.8882319778204, + "p99_ms": 2249.8882319778204, + "max_ms": 2249.8882319778204 + } +] \ No newline at end of file From 5a79cc7195770cafe20785993447b00db663de46 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 15:30:33 -0500 Subject: [PATCH 152/223] Scripts(fix[bench]): Make bench_engines mypy-clean under `mypy .` why: The committed benchmark script failed a repo-wide `mypy .` sweep with 5 errors. Four stem from the `typer` PEP 723 inline dependency, which the repo's mypy environment cannot resolve (cascading into untyped-decorator errors); one is a genuine Server|None narrowing gap. The configured scope (files = [src, tests]) hides them, but a broad `mypy .` surfaces them. what: - Add a file-level disable-error-code for the two typer environment artifacts (import-not-found, untyped-decorator), keeping every other check strict. - Accept `Server | None` in ImsgForServer and assert non-None so the make_engine callable type narrows correctly. --- scripts/bench_engines.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py index 50fd12db8..0130ed899 100644 --- a/scripts/bench_engines.py +++ b/scripts/bench_engines.py @@ -6,6 +6,13 @@ # [tool.uv.sources] # libtmux = { path = "..", editable = true } # /// + +# ``typer`` is a PEP 723 inline dependency, resolved only inside ``uv run``'s +# ephemeral venv; the repo's mypy environment can't import it, which also makes +# its command decorators look untyped. Suppress just those two environment +# artifacts -- every other check (including the ImsgForServer narrowing below) +# stays strict. +# mypy: disable-error-code="import-not-found, untyped-decorator" """Hermetic libtmux engine build-benchmark grid. Measures how long the experimental workspace builder (and the classic API, and a @@ -181,7 +188,8 @@ class ImsgForServer: args -- so this wrapper prepends the isolated socket flag to every request. """ - def __init__(self, server: Server) -> None: + def __init__(self, server: Server | None) -> None: + assert server is not None self._e = ImsgEngine() self._flag = ( f"-S{server.socket_path}" From a89958e3e8c876b168258a9bd6c663a32177e8df Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 17:47:57 -0500 Subject: [PATCH 153/223] Engines(refactor[mock]): Rename ConcreteEngine to MockEngine why: "Concrete" named the in-memory simulator engine, but every real engine (subprocess, control_mode, imsg) is equally a concrete implementation. Across the Python/Rust ecosystem "Concrete*" is a test-only convention for "a minimal instantiable ABC subclass", the opposite of this docs/doctest workhorse, and the word already carries its id/type sense throughout ops/query/objects. "Mock" names the engine by its role: the no-tmux, in-memory stand-in. what: - Rename ConcreteEngine -> MockEngine and AsyncConcreteEngine -> AsyncMockEngine; module concrete.py -> mock.py - EngineKind.CONCRETE ("concrete") -> EngineKind.MOCK ("mock"); EngineSpec.concrete() -> EngineSpec.mock(); registry key becomes "mock" (available_engines() re-sorts accordingly) - Rename the benchmark engine key/label; update RESULTS.md and grid.json to match - Keep docstrings describing the in-memory simulation so the name's test-double flavor does not mislead doctest readers - Leave "concrete" untouched where it means a concrete id/target/pane handle (ops, query, objects, workspace) --- docs/experimental.md | 28 +++++++++---------- scripts/bench-results/RESULTS.md | 6 ++-- scripts/bench-results/grid.json | 10 +++---- scripts/bench_engines.py | 10 +++---- src/libtmux/experimental/engines/__init__.py | 8 +++--- src/libtmux/experimental/engines/base.py | 8 +++--- .../engines/{concrete.py => mock.py} | 14 +++++----- src/libtmux/experimental/engines/registry.py | 10 +++---- src/libtmux/experimental/fluent.py | 20 ++++++------- src/libtmux/experimental/mcp/__init__.py | 2 +- .../experimental/mcp/vocabulary/__init__.py | 4 +-- .../experimental/mcp/vocabulary/_bridge.py | 4 +-- .../experimental/mcp/vocabulary/pane.py | 12 ++++---- .../experimental/mcp/vocabulary/server.py | 4 +-- .../experimental/mcp/vocabulary/session.py | 8 +++--- .../experimental/mcp/vocabulary/window.py | 4 +-- src/libtmux/experimental/objects/client.py | 4 +-- src/libtmux/experimental/objects/pane.py | 12 ++++---- src/libtmux/experimental/objects/server.py | 8 +++--- src/libtmux/experimental/objects/session.py | 8 +++--- src/libtmux/experimental/objects/window.py | 8 +++--- .../experimental/ops/_ops/list_clients.py | 4 +-- .../experimental/ops/_ops/list_panes.py | 4 +-- .../experimental/ops/_ops/list_sessions.py | 4 +-- .../experimental/ops/_ops/list_windows.py | 4 +-- src/libtmux/experimental/ops/plan.py | 14 +++++----- .../experimental/workspace/__init__.py | 4 +-- src/libtmux/experimental/workspace/cli.py | 6 ++-- src/libtmux/experimental/workspace/confirm.py | 2 +- src/libtmux/experimental/workspace/ir.py | 6 ++-- src/libtmux/experimental/workspace/runner.py | 6 ++-- src/libtmux/experimental/workspace/sets.py | 4 +-- .../contract/test_async_control_engine.py | 12 ++++---- ..._async_control_engine_workspace_builder.py | 20 ++++++------- .../contract/test_classic_engine.py | 18 ++++++------ .../contract/test_control_engine.py | 16 +++++------ .../contract/test_engine_contract.py | 16 +++++------ .../contract/test_workspace_floats.py | 8 +++--- .../contract/test_workspace_folding.py | 8 +++--- .../contract/test_workspace_sets.py | 10 +++---- tests/experimental/engines/test_registry.py | 4 +-- tests/experimental/mcp/test_adapter_async.py | 8 ++---- tests/experimental/mcp/test_caller.py | 22 +++++++-------- tests/experimental/mcp/test_events.py | 4 +-- .../experimental/mcp/test_fastmcp_adapter.py | 18 ++++++------ tests/experimental/mcp/test_mcp_projection.py | 8 +++--- tests/experimental/mcp/test_prompts.py | 4 +-- .../mcp/test_relative_special_guard.py | 14 +++++----- tests/experimental/mcp/test_resources.py | 4 +-- tests/experimental/mcp/test_safety_gate.py | 6 ++-- tests/experimental/mcp/test_vocabulary.py | 18 ++++++------ .../mcp/test_vocabulary_extended.py | 20 ++++++------- .../objects/test_matrix_complete.py | 12 ++++---- .../objects/test_object_matrix.py | 12 ++++---- .../experimental/objects/test_pane_object.py | 16 +++++------ tests/experimental/ops/test_ack_ops.py | 4 +-- tests/experimental/ops/test_chain.py | 4 +-- tests/experimental/ops/test_execute.py | 4 +-- tests/experimental/ops/test_plan.py | 28 +++++++++---------- tests/experimental/ops/test_read_ops.py | 4 +-- tests/experimental/test_fluent.py | 6 ++-- tests/experimental/test_freeze.py | 4 +-- tests/experimental/test_query.py | 4 +-- 63 files changed, 291 insertions(+), 295 deletions(-) rename src/libtmux/experimental/engines/{concrete.py => mock.py} (92%) diff --git a/docs/experimental.md b/docs/experimental.md index 8c7c4c873..d3b112412 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -25,8 +25,8 @@ 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()) +>>> from libtmux.experimental.engines import MockEngine +>>> result = run(HasSession(target=SessionId("$0")), MockEngine()) >>> result.ok True >>> result.raise_for_status() is result @@ -46,12 +46,12 @@ 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) | +| ``MockEngine`` | 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``. +``AsyncMockEngine``, ``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: @@ -61,8 +61,8 @@ registry: >>> 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") +('control_mode', 'imsg', 'mock', 'subprocess') +>>> engine = create_engine("mock") >>> run(HasSession(target=SessionId("$0")), engine).status 'complete' ``` @@ -77,11 +77,11 @@ later operation can target something that does not exist yet. ``execute`` ```python >>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys >>> from libtmux.experimental.ops._types import WindowId ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> plan = LazyPlan() >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) >>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) ->>> outcome = plan.execute(ConcreteEngine()) +>>> outcome = plan.execute(MockEngine()) >>> outcome.ok True >>> [r.status for r in outcome.results] @@ -107,11 +107,11 @@ 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 +>>> from libtmux.experimental.engines import MockEngine >>> 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 +>>> plan.execute(MockEngine(), planner=FoldingPlanner()).ok True ``` @@ -125,11 +125,11 @@ description into a few dispatches (its async twin is ``arun``): ```python >>> from libtmux.experimental.fluent import plan ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> p = plan() >>> pane = p.new_session("dev").window().pane() >>> _ = pane.do(lambda c: c.send_keys("vim")).split().do(lambda c: c.send_keys("htop")) ->>> p.run(ConcreteEngine()).ok +>>> p.run(MockEngine()).ok True ``` @@ -147,10 +147,10 @@ duplicating it: ```python >>> from libtmux.experimental.fluent import plan ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> p = plan() >>> _ = p.find_or_create_session("dev").window().pane() ->>> p.run(ConcreteEngine()).ok +>>> p.run(MockEngine()).ok True ``` diff --git a/scripts/bench-results/RESULTS.md b/scripts/bench-results/RESULTS.md index f96debdeb..578bbd6fa 100644 --- a/scripts/bench-results/RESULTS.md +++ b/scripts/bench-results/RESULTS.md @@ -24,7 +24,7 @@ Shape = `windows x panes-per-window`. Structural builds (no shell-readiness wait | builder / imsg | 20.6 | 31.1 | 62.6 | 153.4 | 262.0 (13x) | | builder / control_mode | 2.5 | 9.4 | 26.5 | 103.3 | 166.7 (21x) | | **pipelined (prototype)** | **1.4** | **7.9** | **20.2** | **65.3** | **115.7 (30x)** | -| concrete (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | +| mock (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | Full percentiles at 8x4 (ms): @@ -35,7 +35,7 @@ Full percentiles at 8x4 (ms): | imsg | 222 | 283 | 262 | 342 | 421 | 455 | 455 | | control_mode | 118 | 180 | 167 | 215 | 216 | 398 | 398 | | pipelined | 97 | 123 | 116 | 156 | 165 | 194 | 194 | -| concrete | 1 | 2 | 2 | 2 | 2 | 2 | 2 | +| mock | 1 | 2 | 2 | 2 | 2 | 2 | 2 | Reads: @@ -46,7 +46,7 @@ Reads: - **pipelined** (prototype: batch independent creates into ~3 `run_batch` round-trips instead of ~34) is fastest overall, ~1.4x over control_mode. Not the 11x the round-trip count implies, because the build is **tmux-server-bound** - (one shell fork per pane), not round-trip-bound. `concrete` (offline, 1.5 ms) + (one shell fork per pane), not round-trip-bound. `mock` (offline, 1.5 ms) is the Python floor: the plan/compile layer is negligible; the time is tmux. ## With vs without shell-readiness wait diff --git a/scripts/bench-results/grid.json b/scripts/bench-results/grid.json index 8df01a81e..cfb79abbf 100644 --- a/scripts/bench-results/grid.json +++ b/scripts/bench-results/grid.json @@ -144,7 +144,7 @@ "max_ms": 37.03230095561594 }, { - "engine": "concrete", + "engine": "mock", "shape": "1x1", "panes": 1, "wait": false, @@ -360,7 +360,7 @@ "max_ms": 44.18608907144517 }, { - "engine": "concrete", + "engine": "mock", "shape": "1x4", "panes": 4, "wait": false, @@ -576,7 +576,7 @@ "max_ms": 78.33038899116218 }, { - "engine": "concrete", + "engine": "mock", "shape": "3x3", "panes": 9, "wait": false, @@ -792,7 +792,7 @@ "max_ms": 260.35256509203464 }, { - "engine": "concrete", + "engine": "mock", "shape": "5x4", "panes": 20, "wait": false, @@ -1008,7 +1008,7 @@ "max_ms": 454.6383459819481 }, { - "engine": "concrete", + "engine": "mock", "shape": "8x4", "panes": 32, "wait": false, diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py index 0130ed899..e99f3b703 100644 --- a/scripts/bench_engines.py +++ b/scripts/bench_engines.py @@ -29,7 +29,7 @@ subprocess builder on SubprocessEngine (one tmux fork per op) control_mode builder on ControlModeEngine (one persistent ``tmux -C``) imsg builder on ImsgEngine (AF_UNIX imsg, socket-injected) - concrete builder on ConcreteEngine (offline, in-memory: Python floor) + mock builder on MockEngine (offline, in-memory: Python floor) pipelined prototype: batch independent creates via run_batch (control_mode) Timing (``run`` = in-process build-only, the clean signal; ``--hyperfine`` also @@ -71,9 +71,9 @@ import typer from libtmux.experimental.engines import ( - ConcreteEngine, ControlModeEngine, ImsgEngine, + MockEngine, SubprocessEngine, ) from libtmux.experimental.engines.base import CommandRequest @@ -230,9 +230,7 @@ class Impl: "control_mode", "builder", lambda s: ControlModeEngine.for_server(s) ), "imsg": Impl("imsg", "builder", lambda s: ImsgForServer(s), needs_preboot=True), - "concrete": Impl( - "concrete", "offline", lambda s: ConcreteEngine(), preflight=False - ), + "mock": Impl("mock", "offline", lambda s: MockEngine(), preflight=False), "pipelined": Impl( "pipelined", "pipelined", lambda s: ControlModeEngine.for_server(s) ), @@ -353,7 +351,7 @@ def summarize(samples: list[float]) -> dict[str, float]: def run( shapes: str = typer.Option("1x1,1x4,3x3,5x4,8x4", help="comma WxP shapes"), engines: str = typer.Option( - "classic,subprocess,control_mode,imsg,concrete,pipelined", + "classic,subprocess,control_mode,imsg,mock,pipelined", help="comma engine names", ), wait: bool = typer.Option(False, help="ALSO measure with shell-readiness wait"), diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 3969e73f5..2ad70542c 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -3,7 +3,7 @@ An *engine* executes a rendered tmux command and returns a structured result. Engines are interchangeable behind the :class:`~.base.TmuxEngine` / :class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run -through a subprocess (classic), an in-memory simulator (concrete), a persistent +through a subprocess (classic), an in-memory simulator (mock), a persistent ``tmux -C`` control connection, an async transport, or (as an easter egg) tmux's native binary peer protocol -- and return the *same* typed result. @@ -26,13 +26,13 @@ SupportsTmuxVersion, TmuxEngine, ) -from libtmux.experimental.engines.concrete import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.engines.control_mode import ( ControlModeEngine, ControlModeError, ControlModeParser, ) from libtmux.experimental.engines.imsg import ImsgEngine +from libtmux.experimental.engines.mock import AsyncMockEngine, MockEngine from libtmux.experimental.engines.registry import ( available_engines, create_engine, @@ -41,13 +41,12 @@ from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( - "AsyncConcreteEngine", "AsyncControlModeEngine", + "AsyncMockEngine", "AsyncSubprocessEngine", "AsyncTmuxEngine", "CommandRequest", "CommandResult", - "ConcreteEngine", "ControlModeEngine", "ControlModeError", "ControlModeParser", @@ -55,6 +54,7 @@ "EngineKind", "EngineSpec", "ImsgEngine", + "MockEngine", "SubprocessEngine", "SupportsTmuxVersion", "TmuxEngine", diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 7393ed7ce..b813a12ba 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -100,7 +100,7 @@ class EngineKind(str, enum.Enum): """Named engine families.""" SUBPROCESS = "subprocess" - CONCRETE = "concrete" + MOCK = "mock" CONTROL_MODE = "control_mode" IMSG = "imsg" @@ -139,9 +139,9 @@ def subprocess(cls, *, protocol_version: int | None = None) -> EngineSpec: return cls(kind=EngineKind.SUBPROCESS, protocol_version=protocol_version) @classmethod - def concrete(cls) -> EngineSpec: - """Build a concrete (in-memory) engine spec.""" - return cls(kind=EngineKind.CONCRETE) + def mock(cls) -> EngineSpec: + """Build a mock (in-memory) engine spec.""" + return cls(kind=EngineKind.MOCK) @classmethod def control_mode(cls) -> EngineSpec: diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/mock.py similarity index 92% rename from src/libtmux/experimental/engines/concrete.py rename to src/libtmux/experimental/engines/mock.py index 78863eda8..a7d1fdb21 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/mock.py @@ -1,10 +1,10 @@ """Deterministic, in-memory engines for tests and docs (no tmux server). -The concrete engines simulate just enough tmux behaviour to exercise the +The mock engines simulate just enough tmux behaviour to exercise the operation contract offline: creation commands that ask for an id (``-P -F '#{pane_id}'``) get a fabricated, monotonic id, ``capture-pane`` returns canned lines, and everything else succeeds with empty output. A sync -(:class:`ConcreteEngine`) and async (:class:`AsyncConcreteEngine`) variant share +(:class:`MockEngine`) and async (:class:`AsyncMockEngine`) variant share the same simulation, so the same operation returns the same typed result through either, with no tmux required. """ @@ -65,7 +65,7 @@ def _new_counters() -> dict[str, int]: return {"pane_id": 0, "window_id": 0, "session_id": 0} -class ConcreteEngine: +class MockEngine: """Execute operations against an in-memory simulation (synchronous). Parameters @@ -84,7 +84,7 @@ class ConcreteEngine: -------- >>> from libtmux.experimental.ops import SplitWindow, CapturePane, run >>> from libtmux.experimental.ops._types import WindowId, PaneId - >>> engine = ConcreteEngine(capture_lines=("hello", "world")) + >>> engine = MockEngine(capture_lines=("hello", "world")) >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id '%1' >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id @@ -106,8 +106,8 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: return [self.run(req) for req in requests] -class AsyncConcreteEngine: - """Async sibling of :class:`ConcreteEngine` for offline async tests/docs. +class AsyncMockEngine: + """Async sibling of :class:`MockEngine` for offline async tests/docs. Examples -------- @@ -115,7 +115,7 @@ class AsyncConcreteEngine: >>> from libtmux.experimental.ops import SplitWindow, arun >>> from libtmux.experimental.ops._types import WindowId >>> async def main(): - ... return await arun(SplitWindow(target=WindowId("@1")), AsyncConcreteEngine()) + ... return await arun(SplitWindow(target=WindowId("@1")), AsyncMockEngine()) >>> asyncio.run(main()).new_pane_id '%1' """ diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py index fb9315ea1..b3786f211 100644 --- a/src/libtmux/experimental/engines/registry.py +++ b/src/libtmux/experimental/engines/registry.py @@ -11,8 +11,8 @@ from libtmux import exc from libtmux.experimental.engines.base import EngineKind -from libtmux.experimental.engines.concrete import ConcreteEngine from libtmux.experimental.engines.control_mode import ControlModeEngine +from libtmux.experimental.engines.mock import MockEngine from libtmux.experimental.engines.subprocess import SubprocessEngine if t.TYPE_CHECKING: @@ -34,7 +34,7 @@ def available_engines() -> tuple[str, ...]: Examples -------- >>> from libtmux.experimental.engines import available_engines - >>> "concrete" in available_engines() + >>> "mock" in available_engines() True >>> "subprocess" in available_engines() True @@ -48,8 +48,8 @@ def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: Examples -------- >>> from libtmux.experimental.engines import create_engine - >>> create_engine("concrete") - + >>> create_engine("mock") + >>> create_engine("nope") Traceback (most recent call last): ... @@ -65,5 +65,5 @@ def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: register_engine(EngineKind.SUBPROCESS.value, SubprocessEngine) -register_engine(EngineKind.CONCRETE.value, ConcreteEngine) +register_engine(EngineKind.MOCK.value, MockEngine) register_engine(EngineKind.CONTROL_MODE.value, ControlModeEngine) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index 2fd913bc9..24fc58d59 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -17,7 +17,7 @@ Examples -------- ->>> from libtmux.experimental.engines.concrete import ConcreteEngine +>>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> pane = p.new_session("dev").window().pane() >>> bottom = pane.do(lambda c: c.send_keys("vim")).split() @@ -25,7 +25,7 @@ True >>> [op.kind for op in p.plan.operations] ['new_session', 'send_keys', 'split_window', 'send_keys'] ->>> p.run(ConcreteEngine()).ok +>>> p.run(MockEngine()).ok True """ @@ -172,12 +172,12 @@ def find_or_create_session(self, name: str) -> SessionRef: Examples -------- - >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> _ = p.find_or_create_session("dev").window().pane() >>> [op.kind for op in p.plan.operations] ['new_session'] - >>> p.run(ConcreteEngine()).ok + >>> p.run(MockEngine()).ok True """ create = NewSession(session_name=name, capture_panes=True) @@ -196,11 +196,11 @@ def sleep(self, seconds: float) -> PlanBuilder: Examples -------- - >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> pane = p.new_session("dev").window().pane() >>> _ = pane.do(lambda c: c.send_keys("slow-start")) - >>> p.sleep(0.0).run(ConcreteEngine()).ok + >>> p.sleep(0.0).run(MockEngine()).ok True """ self._record_host(_HostAction("sleep", seconds=seconds)) @@ -246,10 +246,10 @@ def run( Examples -------- - >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) - >>> p.run(ConcreteEngine()).ok + >>> p.run(MockEngine()).ok True """ @@ -286,10 +286,10 @@ async def arun( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.engines.mock import AsyncMockEngine >>> p = plan() >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) - >>> asyncio.run(p.arun(AsyncConcreteEngine())).ok + >>> asyncio.run(p.arun(AsyncMockEngine())).ok True """ diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 074e1254a..c754b0516 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -14,7 +14,7 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> reg = OperationToolRegistry() >>> reg.descriptor("new_session").safety 'mutating' diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py index bc5e26b45..a6330652d 100644 --- a/src/libtmux/experimental/mcp/vocabulary/__init__.py +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -11,8 +11,8 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine ->>> engine = ConcreteEngine() +>>> from libtmux.experimental.engines import MockEngine +>>> engine = MockEngine() >>> session = create_session(engine, name="dev") >>> session.session_id '$1' diff --git a/src/libtmux/experimental/mcp/vocabulary/_bridge.py b/src/libtmux/experimental/mcp/vocabulary/_bridge.py index db78380d7..0bbabe6a9 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_bridge.py +++ b/src/libtmux/experimental/mcp/vocabulary/_bridge.py @@ -41,8 +41,8 @@ class SyncToAsyncEngine: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> bridge = SyncToAsyncEngine(ConcreteEngine()) + >>> from libtmux.experimental.engines import MockEngine + >>> bridge = SyncToAsyncEngine(MockEngine()) >>> hasattr(bridge, "run") and hasattr(bridge, "run_batch") True """ diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 8e14a6db2..35a2b99d0 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -217,8 +217,8 @@ async def acapture_active_pane( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> capture_active_pane(ConcreteEngine(capture_lines=("hi",))).lines + >>> from libtmux.experimental.engines import MockEngine + >>> capture_active_pane(MockEngine(capture_lines=("hi",))).lines ('hi',) """ result = await arun( @@ -252,8 +252,8 @@ async def agrep_pane( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + >>> from libtmux.experimental.engines import MockEngine + >>> engine = MockEngine(capture_lines=("foo", "bar baz", "foobar")) >>> grep_pane(engine, "%1", "foo").lines ('foo', 'foobar') """ @@ -502,8 +502,8 @@ async def aselect_pane( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> select_pane(ConcreteEngine(), "%1", direction="left") + >>> from libtmux.experimental.engines import MockEngine + >>> select_pane(MockEngine(), "%1", direction="left") PaneRef(pane_id=None) """ if direction in DIR_FLAG: diff --git a/src/libtmux/experimental/mcp/vocabulary/server.py b/src/libtmux/experimental/mcp/vocabulary/server.py index d4f166746..83c9cc90e 100644 --- a/src/libtmux/experimental/mcp/vocabulary/server.py +++ b/src/libtmux/experimental/mcp/vocabulary/server.py @@ -53,8 +53,8 @@ async def arun_tmux( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> run_tmux(ConcreteEngine(), ["list-sessions"]).ok + >>> from libtmux.experimental.engines import MockEngine + >>> run_tmux(MockEngine(), ["list-sessions"]).ok True """ raw = await engine.run(CommandRequest.from_args(*args)) diff --git a/src/libtmux/experimental/mcp/vocabulary/session.py b/src/libtmux/experimental/mcp/vocabulary/session.py index e5e0e65e1..0b539ae22 100644 --- a/src/libtmux/experimental/mcp/vocabulary/session.py +++ b/src/libtmux/experimental/mcp/vocabulary/session.py @@ -39,8 +39,8 @@ async def acreate_session( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> r = create_session(ConcreteEngine(), name="work") + >>> from libtmux.experimental.engines import MockEngine + >>> r = create_session(MockEngine(), name="work") >>> (r.session_id, r.name, r.first_pane_id) ('$1', 'work', '%1') """ @@ -117,8 +117,8 @@ async def ahas_session( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> has_session(ConcreteEngine(), "$1") + >>> from libtmux.experimental.engines import MockEngine + >>> has_session(MockEngine(), "$1") True """ result = await arun( diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index dc10b2fc5..ef0fa85c5 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -37,8 +37,8 @@ async def acreate_window( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> w = create_window(ConcreteEngine(), "$1", name="logs") + >>> from libtmux.experimental.engines import MockEngine + >>> w = create_window(MockEngine(), "$1", name="logs") >>> w.window_id.startswith("@"), w.name (True, 'logs') """ diff --git a/src/libtmux/experimental/objects/client.py b/src/libtmux/experimental/objects/client.py index fb8de36de..435bcf608 100644 --- a/src/libtmux/experimental/objects/client.py +++ b/src/libtmux/experimental/objects/client.py @@ -31,8 +31,8 @@ class EagerClient: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> client = EagerClient(ConcreteEngine(), "/dev/pts/3") + >>> from libtmux.experimental.engines import MockEngine + >>> client = EagerClient(MockEngine(), "/dev/pts/3") >>> client.refresh().ok True >>> client.switch_to("$1").ok diff --git a/src/libtmux/experimental/objects/pane.py b/src/libtmux/experimental/objects/pane.py index 419642a30..ff4a5cef9 100644 --- a/src/libtmux/experimental/objects/pane.py +++ b/src/libtmux/experimental/objects/pane.py @@ -93,8 +93,8 @@ class EagerPane: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> pane = EagerPane(ConcreteEngine(), "%0") + >>> from libtmux.experimental.engines import MockEngine + >>> pane = EagerPane(MockEngine(), "%0") >>> child = pane.split(horizontal=True) >>> child.pane_id '%1' @@ -208,14 +208,14 @@ class LazyPane: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> from libtmux.experimental.ops._types import PaneId >>> plan = LazyPlan() >>> root = LazyPane(plan, PaneId("%0")) >>> child = root.split() >>> _ = child.send_keys("vim", enter=True) - >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome = plan.execute(MockEngine()) >>> outcome.results[0].new_pane_id '%1' >>> outcome.results[1].argv @@ -305,9 +305,9 @@ class AsyncPane: Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> async def main(): - ... pane = AsyncPane(AsyncConcreteEngine(), "%0") + ... pane = AsyncPane(AsyncMockEngine(), "%0") ... child = await pane.split(horizontal=True) ... return child.pane_id >>> asyncio.run(main()) diff --git a/src/libtmux/experimental/objects/server.py b/src/libtmux/experimental/objects/server.py index 085d6cb16..fccc7de27 100644 --- a/src/libtmux/experimental/objects/server.py +++ b/src/libtmux/experimental/objects/server.py @@ -23,8 +23,8 @@ class EagerServer: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> server = EagerServer(ConcreteEngine()) + >>> from libtmux.experimental.engines import MockEngine + >>> server = EagerServer(MockEngine()) >>> session = server.new_session(name="work") >>> session.session_id '$1' @@ -66,13 +66,13 @@ class LazyServer: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> plan = LazyPlan() >>> server = LazyServer(plan) >>> session = server.new_session(name="work") >>> _ = session.new_window(name="build") - >>> plan.execute(ConcreteEngine()).ok + >>> plan.execute(MockEngine()).ok True """ diff --git a/src/libtmux/experimental/objects/session.py b/src/libtmux/experimental/objects/session.py index 14c1c1447..7f1adcbd1 100644 --- a/src/libtmux/experimental/objects/session.py +++ b/src/libtmux/experimental/objects/session.py @@ -28,8 +28,8 @@ class EagerSession: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> session = EagerSession(ConcreteEngine(), "$0") + >>> from libtmux.experimental.engines import MockEngine + >>> session = EagerSession(MockEngine(), "$0") >>> window = session.new_window(name="build") >>> window.window_id '@1' @@ -84,14 +84,14 @@ class LazySession: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> from libtmux.experimental.ops._types import SessionId >>> plan = LazyPlan() >>> session = LazySession(plan, SessionId("$0")) >>> window = session.new_window(name="build") >>> _ = session.rename("work") - >>> plan.execute(ConcreteEngine()).ok + >>> plan.execute(MockEngine()).ok True """ diff --git a/src/libtmux/experimental/objects/window.py b/src/libtmux/experimental/objects/window.py index 7bb827c09..582d2e7dd 100644 --- a/src/libtmux/experimental/objects/window.py +++ b/src/libtmux/experimental/objects/window.py @@ -35,8 +35,8 @@ class EagerWindow: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> window = EagerWindow(ConcreteEngine(), "@1") + >>> from libtmux.experimental.engines import MockEngine + >>> window = EagerWindow(MockEngine(), "@1") >>> pane = window.split(horizontal=True) >>> pane.pane_id '%1' @@ -101,14 +101,14 @@ class LazyWindow: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> from libtmux.experimental.ops._types import WindowId >>> plan = LazyPlan() >>> window = LazyWindow(plan, WindowId("@1")) >>> pane = window.split() >>> _ = window.rename("build") - >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome = plan.execute(MockEngine()) >>> outcome.ok True """ diff --git a/src/libtmux/experimental/ops/_ops/list_clients.py b/src/libtmux/experimental/ops/_ops/list_clients.py index dfc1023f1..f48e5c2e7 100644 --- a/src/libtmux/experimental/ops/_ops/list_clients.py +++ b/src/libtmux/experimental/ops/_ops/list_clients.py @@ -26,9 +26,9 @@ class ListClients(Operation[ListClientsResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run - >>> run(ListClients(), ConcreteEngine(), version="3.6a").clients + >>> run(ListClients(), MockEngine(), version="3.6a").clients () """ diff --git a/src/libtmux/experimental/ops/_ops/list_panes.py b/src/libtmux/experimental/ops/_ops/list_panes.py index 702a4d740..23347f861 100644 --- a/src/libtmux/experimental/ops/_ops/list_panes.py +++ b/src/libtmux/experimental/ops/_ops/list_panes.py @@ -36,14 +36,14 @@ class ListPanes(Operation[ListPanesResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run >>> op = ListPanes() >>> op.render(version="3.6a")[:1] ('list-panes',) >>> "-a" in op.render(version="3.6a") True - >>> result = run(op, ConcreteEngine(), version="3.6a") + >>> result = run(op, MockEngine(), version="3.6a") >>> result.rows () >>> result.server.sessions diff --git a/src/libtmux/experimental/ops/_ops/list_sessions.py b/src/libtmux/experimental/ops/_ops/list_sessions.py index c120b06f1..f9f598896 100644 --- a/src/libtmux/experimental/ops/_ops/list_sessions.py +++ b/src/libtmux/experimental/ops/_ops/list_sessions.py @@ -26,9 +26,9 @@ class ListSessions(Operation[ListSessionsResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run - >>> run(ListSessions(), ConcreteEngine(), version="3.6a").sessions + >>> run(ListSessions(), MockEngine(), version="3.6a").sessions () """ diff --git a/src/libtmux/experimental/ops/_ops/list_windows.py b/src/libtmux/experimental/ops/_ops/list_windows.py index 3126c58b3..16d60e4a6 100644 --- a/src/libtmux/experimental/ops/_ops/list_windows.py +++ b/src/libtmux/experimental/ops/_ops/list_windows.py @@ -31,9 +31,9 @@ class ListWindows(Operation[ListWindowsResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run - >>> run(ListWindows(), ConcreteEngine(), version="3.6a").windows + >>> run(ListWindows(), MockEngine(), version="3.6a").windows () """ diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index a3cc7485e..27a5294c5 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -228,15 +228,15 @@ class LazyPlan: Examples -------- Build a plan that splits a window then types into the *new* pane, and run it - against the in-memory concrete engine (no tmux required): + against the in-memory mock engine (no tmux required): >>> from libtmux.experimental.ops import SplitWindow, SendKeys >>> from libtmux.experimental.ops._types import WindowId - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> plan = LazyPlan() >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) >>> _ = plan.add(SendKeys(target=pane, keys="vim", enter=True)) - >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome = plan.execute(MockEngine()) >>> outcome.bindings {0: '%1'} >>> outcome.results[1].argv @@ -511,12 +511,12 @@ async def aexecute( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.engines.mock import AsyncMockEngine >>> from libtmux.experimental.ops import SendKeys >>> from libtmux.experimental.ops._types import PaneId >>> plan = LazyPlan() >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) - >>> asyncio.run(plan.aexecute(AsyncConcreteEngine())).ok + >>> asyncio.run(plan.aexecute(AsyncMockEngine())).ok True """ version = resolve_engine_version(engine, version) @@ -572,13 +572,13 @@ async def astream( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.engines.mock import AsyncMockEngine >>> from libtmux.experimental.ops import SendKeys >>> from libtmux.experimental.ops._types import PaneId >>> plan = LazyPlan() >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) >>> async def drain() -> list[str]: - ... engine = AsyncConcreteEngine() + ... engine = AsyncMockEngine() ... return [type(e).__name__ async for e in plan.astream(engine)] >>> asyncio.run(drain()) ['StepDone', 'PlanDone'] diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 1e7126c39..4d9d7cceb 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -11,12 +11,12 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> ws = analyze({ ... "session_name": "dev", ... "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], ... }) ->>> ws.build(ConcreteEngine(), preflight=False).ok +>>> ws.build(MockEngine(), preflight=False).ok True """ diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py index 4489d4d10..b1c1f832f 100644 --- a/src/libtmux/experimental/workspace/cli.py +++ b/src/libtmux/experimental/workspace/cli.py @@ -207,7 +207,7 @@ def _print_dry_run( ) -> None: r"""Print the tmux commands a build would run, without touching tmux. - The plan is resolved against the in-memory ``ConcreteEngine`` (which + The plan is resolved against the in-memory ``MockEngine`` (which fabricates ids) through the *same* planner the real build uses, so the printed lines are the folded ``;`` dispatches that would actually run -- not an unfolded op-per-line view. Pass ``fold=False`` for one tmux call per @@ -217,7 +217,7 @@ def _print_dry_run( """ import shlex - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( BoundedPlanner, MarkedPlanner, @@ -237,7 +237,7 @@ def _print_dry_run( if fold else SequentialPlanner() ) - engine = _RecordingEngine(ConcreteEngine()) + engine = _RecordingEngine(MockEngine()) hosts_per_dispatch: list[tuple[HostStep, ...]] = [] def on_step(report: StepReport) -> None: diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py index cc92394d9..80e9054c3 100644 --- a/src/libtmux/experimental/workspace/confirm.py +++ b/src/libtmux/experimental/workspace/confirm.py @@ -2,7 +2,7 @@ Reads the live server through the classic libtmux objects and diffs the observed session/window/pane structure against the declared :class:`~.ir.Workspace`. Used by -the live test track; the offline (``ConcreteEngine``) track asserts on the +the live test track; the offline (``MockEngine``) track asserts on the compiled plan instead, since a stateless engine has no structure to read back. """ diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 3dd57305d..5a850b5aa 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -9,7 +9,7 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane >>> ws = Workspace( ... name="dev", @@ -23,7 +23,7 @@ ... ) >>> ws.compile().operations[0].kind 'new_session' ->>> ws.build(ConcreteEngine(), preflight=False).ok +>>> ws.build(MockEngine(), preflight=False).ok True """ @@ -463,7 +463,7 @@ def build( """Compile and execute this workspace synchronously over *engine*. Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check - (e.g. against the stateless ``ConcreteEngine``, which has no real + (e.g. against the stateless ``MockEngine``, which has no real sessions to detect). Pass *on_event* to observe the structural build stream (see :mod:`~.events`). The build folds dispatches by default; pass *planner* (e.g. :class:`~..ops.planner.SequentialPlanner`) to override. diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index 5bd9e8b32..f756d9e81 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -17,7 +17,7 @@ only difference is ``run`` vs ``await arun`` and the host-step executor. ``preflight=False`` skips the ``on_exists`` ``has-session`` check; use it offline -against the stateless ``ConcreteEngine`` (whose ``has-session`` is always true). +against the stateless ``MockEngine`` (whose ``has-session`` is always true). """ from __future__ import annotations @@ -156,10 +156,10 @@ def build_workspace( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane >>> ws = Workspace(name="dev", windows=[Window("w", panes=[Pane(run="vim")])]) - >>> build_workspace(ws, ConcreteEngine(), preflight=False).ok + >>> build_workspace(ws, MockEngine(), preflight=False).ok True """ if preflight and _preflight_sync(ws, engine, version): diff --git a/src/libtmux/experimental/workspace/sets.py b/src/libtmux/experimental/workspace/sets.py index 26dfddadb..8ef9f18ad 100644 --- a/src/libtmux/experimental/workspace/sets.py +++ b/src/libtmux/experimental/workspace/sets.py @@ -97,10 +97,10 @@ class WorkspaceSet: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.workspace import Pane, Window, Workspace >>> ws = Workspace("dev", windows=[Window("w", panes=[Pane("echo hi")])]) - >>> WorkspaceSet((ws,)).build(ConcreteEngine(), preflight=False).ok + >>> WorkspaceSet((ws,)).build(MockEngine(), preflight=False).ok True """ diff --git a/tests/experimental/contract/test_async_control_engine.py b/tests/experimental/contract/test_async_control_engine.py index bcdc24580..116ee99cb 100644 --- a/tests/experimental/contract/test_async_control_engine.py +++ b/tests/experimental/contract/test_async_control_engine.py @@ -11,8 +11,8 @@ import typing as t from libtmux.experimental.engines import ( - AsyncConcreteEngine, AsyncControlModeEngine, + AsyncMockEngine, CommandRequest, ControlNotification, ) @@ -90,8 +90,8 @@ async def main() -> tuple[str | None, str | None]: assert first != second -def test_async_control_concrete_parity(session: Session) -> None: - """Async control-mode and concrete engines agree on result type and argv.""" +def test_async_control_mock_parity(session: Session) -> None: + """Async control-mode and mock engines agree on result type and argv.""" server = session.server window_id = session.active_window.window_id assert window_id is not None @@ -102,9 +102,9 @@ async def main() -> SplitWindowResult: return await arun(operation, engine) control = asyncio.run(main()) - concrete = asyncio.run(arun(operation, AsyncConcreteEngine())) - assert type(control) is type(concrete) is SplitWindowResult - assert control.argv == concrete.argv == operation.render() + mock = asyncio.run(arun(operation, AsyncMockEngine())) + assert type(control) is type(mock) is SplitWindowResult + assert control.argv == mock.argv == operation.render() def test_async_control_event_stream(session: Session) -> None: diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 2bf4bd8b7..11cbf4680 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -4,7 +4,7 @@ (:mod:`libtmux.experimental.workspace`): a YAML/dict is analyzed into a structural ``Workspace`` spec, compiled to a Core ``LazyPlan``, and executed. Two tracks: -* **offline** -- compile against the in-memory ``ConcreteEngine`` and assert the +* **offline** -- compile against the in-memory ``MockEngine`` and assert the op sequence and planner-equivalence (no tmux); * **live** -- build over the async control-mode engine *and* the sync subprocess engine against a real tmux server, then confirm the live structure matches the @@ -19,9 +19,9 @@ import pytest from libtmux.experimental.engines import ( - AsyncConcreteEngine, AsyncControlModeEngine, - ConcreteEngine, + AsyncMockEngine, + MockEngine, SubprocessEngine, ) from libtmux.experimental.ops import ( @@ -129,8 +129,8 @@ def test_workspace_compiles_to_core_ops() -> None: def test_workspace_offline_build_and_planner_equivalence() -> None: """The compiled plan runs offline and the optimizer preserves the result.""" plan = analyze(_YAML).compile() - sequential = plan.execute(ConcreteEngine(), planner=SequentialPlanner()) - folded = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + sequential = plan.execute(MockEngine(), planner=SequentialPlanner()) + folded = plan.execute(MockEngine(), planner=FoldingPlanner()) assert sequential.ok assert folded.ok assert [r.status for r in sequential.results] == [r.status for r in folded.results] @@ -152,7 +152,7 @@ def test_first_pane_focus_multipane_compiles() -> None: plan = ws.compile() assert "select_pane" in [op.kind for op in plan.operations] # offline execution resolves the first-pane sub-ref without error - assert plan.execute(ConcreteEngine()).ok + assert plan.execute(MockEngine()).ok def test_empty_workspace_is_rejected() -> None: @@ -274,7 +274,7 @@ def test_workspace_all_planners_agree(tmp_path: Path) -> None: """Sequential, Folding, and Marked planners give an identical PlanResult.""" plan = _rich_spec(str(tmp_path)).compile() runs = { - name: plan.execute(ConcreteEngine(), planner=planner()) + name: plan.execute(MockEngine(), planner=planner()) for name, planner in ( ("sequential", SequentialPlanner), ("folding", FoldingPlanner), @@ -752,7 +752,7 @@ def test_build_emits_event_stream_sync_and_async() -> None: ) sync_events: list[BuildEvent] = [] - ws.build(ConcreteEngine(), preflight=False, on_event=sync_events.append) + ws.build(MockEngine(), preflight=False, on_event=sync_events.append) async_events: list[BuildEvent] = [] @@ -760,7 +760,7 @@ async def collect(event: BuildEvent) -> None: async_events.append(event) async def main() -> None: - await ws.abuild(AsyncConcreteEngine(), preflight=False, on_event=collect) + await ws.abuild(AsyncMockEngine(), preflight=False, on_event=collect) asyncio.run(main()) @@ -864,7 +864,7 @@ def test_compile_window_index_targets_session_index() -> None: Window("b", window_index=5, panes=[Pane(run="echo y")]), ], ) - results = ws.build(ConcreteEngine(), preflight=False).results + results = ws.build(MockEngine(), preflight=False).results new_window = next(r for r in results if r.argv[0] == "new-window") assert "$1:5" in new_window.argv # targeted at the explicit session index assert new_window.ok # the -P -F capture still binds the new window id diff --git a/tests/experimental/contract/test_classic_engine.py b/tests/experimental/contract/test_classic_engine.py index 927a6be3a..9db74db4e 100644 --- a/tests/experimental/contract/test_classic_engine.py +++ b/tests/experimental/contract/test_classic_engine.py @@ -1,8 +1,8 @@ -"""Classic engine against a real tmux server, and parity with concrete. +"""Classic engine against a real tmux server, and parity with mock. These use the libtmux pytest fixtures (a live tmux server), so they exercise the classic :class:`~libtmux.experimental.engines.subprocess.SubprocessEngine` path -end to end and assert it returns the *same typed result shape* the concrete +end to end and assert it returns the *same typed result shape* the mock engine does. """ @@ -10,7 +10,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.ops import ( CapturePane, SelectLayout, @@ -81,16 +81,16 @@ def test_classic_capture_returns_lines(session: Session) -> None: assert isinstance(result.lines, tuple) -def test_classic_concrete_parity(session: Session) -> None: - """Classic and concrete engines agree on result type and argv (not payload).""" +def test_classic_mock_parity(session: Session) -> None: + """Classic and mock engines agree on result type and argv (not payload).""" server = session.server window = session.active_window assert window.window_id is not None operation = SplitWindow(target=WindowId(window.window_id)) classic = run(operation, SubprocessEngine.for_server(server)) - concrete = run(operation, ConcreteEngine()) + mock = run(operation, MockEngine()) - assert type(classic) is type(concrete) is SplitWindowResult - assert classic.argv == concrete.argv == operation.render() - assert classic.ok and concrete.ok + assert type(classic) is type(mock) is SplitWindowResult + assert classic.argv == mock.argv == operation.render() + assert classic.ok and mock.ok diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py index dd280cc98..a92d4ae77 100644 --- a/tests/experimental/contract/test_control_engine.py +++ b/tests/experimental/contract/test_control_engine.py @@ -1,4 +1,4 @@ -"""Control-mode engine against a real tmux server, and parity with concrete. +"""Control-mode engine against a real tmux server, and parity with mock. Exercises the persistent ``tmux -C`` engine end to end and asserts it returns the same typed result shape the other engines do. The engine is used as a @@ -9,7 +9,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine, ControlModeEngine +from libtmux.experimental.engines import ControlModeEngine, MockEngine from libtmux.experimental.ops import ( CapturePane, SendKeys, @@ -84,8 +84,8 @@ def test_control_batches_multiple_commands(session: Session) -> None: assert captured.ok -def test_control_concrete_parity(session: Session) -> None: - """Control-mode and concrete engines agree on result type and argv.""" +def test_control_mock_parity(session: Session) -> None: + """Control-mode and mock engines agree on result type and argv.""" server = session.server window = session.active_window assert window.window_id is not None @@ -93,8 +93,8 @@ def test_control_concrete_parity(session: Session) -> None: with ControlModeEngine.for_server(server) as engine: control = run(operation, engine) - concrete = run(operation, ConcreteEngine()) + mock = run(operation, MockEngine()) - assert type(control) is type(concrete) is SplitWindowResult - assert control.argv == concrete.argv == operation.render() - assert control.ok and concrete.ok + assert type(control) is type(mock) is SplitWindowResult + assert control.argv == mock.argv == operation.render() + assert control.ok and mock.ok diff --git a/tests/experimental/contract/test_engine_contract.py b/tests/experimental/contract/test_engine_contract.py index 34e60096c..0df490463 100644 --- a/tests/experimental/contract/test_engine_contract.py +++ b/tests/experimental/contract/test_engine_contract.py @@ -1,9 +1,9 @@ -"""Engine-agnostic operation contract (runs offline via the concrete engine). +"""Engine-agnostic operation contract (runs offline via the mock engine). These assertions hold for *any* engine because they are properties of the operation executed through the engine: the result is the operation's typed result class, its argv is the operation's render, and it serializes round-trip. -The concrete engine lets the whole matrix run without a tmux server. +The mock engine lets the whole matrix run without a tmux server. """ from __future__ import annotations @@ -12,7 +12,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( CapturePane, SelectLayout, @@ -40,14 +40,14 @@ @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_result_type_matches_operation(operation: Operation[t.Any]) -> None: """An engine returns the operation's declared result type.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert type(result) is operation.result_cls @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_result_argv_is_render(operation: Operation[t.Any]) -> None: """The result's argv equals the operation's pure render.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert result.argv == operation.render() assert result.ok @@ -55,13 +55,13 @@ def test_result_argv_is_render(operation: Operation[t.Any]) -> None: @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_result_serialization_round_trip(operation: Operation[t.Any]) -> None: """A result produced by an engine survives a dict round-trip.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert result_from_dict(result_to_dict(result)) == result @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_same_result_across_engine_instances(operation: Operation[t.Any]) -> None: """Two fresh engines yield equal typed results -- determinism contract.""" - first = run(operation, ConcreteEngine()) - second = run(operation, ConcreteEngine()) + first = run(operation, MockEngine()) + second = run(operation, MockEngine()) assert first == second diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py index a91151780..f515aa6d8 100644 --- a/tests/experimental/contract/test_workspace_floats.py +++ b/tests/experimental/contract/test_workspace_floats.py @@ -157,7 +157,7 @@ def test_cross_window_float_attaches_to_later_window() -> None: def test_cross_window_float_builds_offline() -> None: """A cross-window float resolves its target and builds in-memory.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine ws = Workspace( name="dev", @@ -170,7 +170,7 @@ def test_cross_window_float_builds_offline() -> None: Window("logs", panes=[Pane(run="tail -f x")]), ], ) - assert ws.build(ConcreteEngine(), preflight=False).ok + assert ws.build(MockEngine(), preflight=False).ok def test_unknown_attach_to_raises() -> None: @@ -210,7 +210,7 @@ def test_topo_order_detects_cycle() -> None: def test_offline_build_with_float() -> None: """A float-bearing workspace builds over the in-memory engine.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine ws = Workspace( name="dev", @@ -227,7 +227,7 @@ def test_offline_build_with_float() -> None: ), ], ) - assert ws.build(ConcreteEngine(), preflight=False).ok + assert ws.build(MockEngine(), preflight=False).ok def test_events_for_new_pane() -> None: diff --git a/tests/experimental/contract/test_workspace_folding.py b/tests/experimental/contract/test_workspace_folding.py index 3f69ec4c3..9e9a8dd38 100644 --- a/tests/experimental/contract/test_workspace_folding.py +++ b/tests/experimental/contract/test_workspace_folding.py @@ -13,7 +13,7 @@ import dataclasses import typing as t -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import SequentialPlanner from libtmux.experimental.workspace import Command, Pane, Window, Workspace, analyze @@ -35,7 +35,7 @@ class _RecordingEngine: fast. """ - inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + inner: TmuxEngine = dataclasses.field(default_factory=MockEngine) calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) def run(self, request: CommandRequest) -> CommandResult: @@ -83,9 +83,9 @@ def test_build_folds_by_default() -> None: def test_build_planner_equivalence() -> None: """The default (folding) build yields the same PlanResult as the sequential one.""" - folded = _spec().build(ConcreteEngine(), preflight=False) + folded = _spec().build(MockEngine(), preflight=False) sequential = _spec().build( - ConcreteEngine(), + MockEngine(), preflight=False, planner=SequentialPlanner(), ) diff --git a/tests/experimental/contract/test_workspace_sets.py b/tests/experimental/contract/test_workspace_sets.py index 4a2f4bd37..5afc72af0 100644 --- a/tests/experimental/contract/test_workspace_sets.py +++ b/tests/experimental/contract/test_workspace_sets.py @@ -6,7 +6,7 @@ import dataclasses import typing as t -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import SequentialPlanner from libtmux.experimental.ops._types import SlotRef @@ -31,7 +31,7 @@ class _RecordingEngine: """Record dispatches while forwarding to an inner engine.""" - inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + inner: TmuxEngine = dataclasses.field(default_factory=MockEngine) calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) def run(self, request: CommandRequest) -> CommandResult: @@ -127,7 +127,7 @@ def test_workspace_set_all_reused_returns_noop_result() -> None: windows=[Window("w", panes=[Pane("echo nope")])], on_exists="reuse", ) - engine = ConcreteEngine() + engine = MockEngine() first = build_workspaces([reused], engine, preflight=False) second = build_workspaces([reused], engine) @@ -143,7 +143,7 @@ def test_workspace_set_emits_built_event_per_workspace() -> None: events: list[BuildEvent] = [] outcome = build_workspaces( [_workspace("one"), _workspace("two")], - ConcreteEngine(), + MockEngine(), preflight=False, on_event=events.append, ) @@ -157,7 +157,7 @@ def test_workspace_set_async_build_matches_sync_shape() -> None: """The async runner exposes the same result shape as the sync runner.""" workspace_set = WorkspaceSet((_workspace("one"), _workspace("two"))) outcome = asyncio.run( - workspace_set.abuild(AsyncConcreteEngine(), preflight=False), + workspace_set.abuild(AsyncMockEngine(), preflight=False), ) assert outcome.ok diff --git a/tests/experimental/engines/test_registry.py b/tests/experimental/engines/test_registry.py index 7e315cc60..b11a5ae7b 100644 --- a/tests/experimental/engines/test_registry.py +++ b/tests/experimental/engines/test_registry.py @@ -19,7 +19,7 @@ def test_available_engines_are_registered() -> None: """The registry exposes exactly the constructable (sync) engine kinds.""" assert set(available_engines()) == { "subprocess", - "concrete", + "mock", "control_mode", "imsg", } @@ -40,7 +40,7 @@ class CreateCase(t.NamedTuple): CREATE_CASES = ( CreateCase("subprocess", "subprocess"), - CreateCase("concrete", "concrete"), + CreateCase("mock", "mock"), CreateCase("control_mode", "control_mode"), ) diff --git a/tests/experimental/mcp/test_adapter_async.py b/tests/experimental/mcp/test_adapter_async.py index e34890b3c..e10acb15a 100644 --- a/tests/experimental/mcp/test_adapter_async.py +++ b/tests/experimental/mcp/test_adapter_async.py @@ -1,6 +1,6 @@ """The async-first FastMCP adapter -- awaited tools, per-op, and plan tiers. -Exercised offline via an in-process FastMCP client over a sync ``ConcreteEngine`` +Exercised offline via an in-process FastMCP client over a sync ``MockEngine`` wrapped into the async protocol, so the async registration path is validated with no tmux. """ @@ -12,7 +12,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine fastmcp = pytest.importorskip("fastmcp") @@ -22,9 +22,7 @@ def _async_server(**kwargs: t.Any) -> t.Any: """Build an async server over a wrapped in-memory engine.""" from libtmux.experimental.mcp.fastmcp_adapter import build_async_server - return build_async_server( - SyncToAsyncEngine(ConcreteEngine()), events="off", **kwargs - ) + return build_async_server(SyncToAsyncEngine(MockEngine()), events="off", **kwargs) def test_async_server_exposes_curated_and_conveniences() -> None: diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index 317b0c342..dc0270d09 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -14,7 +14,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.vocabulary import ( create_session, kill_pane, @@ -113,7 +113,7 @@ def test_get_caller_context_tool(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%7") monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") - server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + server = build_async_server(SyncToAsyncEngine(MockEngine()), events="off") async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -130,7 +130,7 @@ def test_instructions_include_caller(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%7") monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") - server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + server = build_async_server(SyncToAsyncEngine(MockEngine()), events="off") assert "%7" in (server.instructions or "") assert "is_caller" in (server.instructions or "") @@ -143,7 +143,7 @@ def test_instructions_outside_tmux(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TMUX_PANE", raising=False) # Disable the /proc parent walk so a test runner inside tmux is not discovered. monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") - server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + server = build_async_server(SyncToAsyncEngine(MockEngine()), events="off") assert "not running inside a tmux pane" in (server.instructions or "") @@ -185,7 +185,7 @@ def test_resolve_origin_same_server_uses_caller( """resolve_origin trusts the caller pane when the engine shares its server.""" monkeypatch.setenv("TMUX_PANE", "%3") monkeypatch.setenv("TMUX", "/tmp/a,1,2") - engine = SyncToAsyncEngine(ConcreteEngine()) # default socket -> ambient server + engine = SyncToAsyncEngine(MockEngine()) # default socket -> ambient server async def main() -> str: return await resolve_origin(engine, None, None) @@ -203,7 +203,7 @@ def test_resolve_origin_cross_server_requires_explicit( class CrossServer(SyncToAsyncEngine): server_args = ("-S", "/tmp/b") # a different socket than the caller's - engine = CrossServer(ConcreteEngine()) + engine = CrossServer(MockEngine()) async def main() -> str: return await resolve_origin(engine, None, None) @@ -309,7 +309,7 @@ def test_kill_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") with pytest.raises(ToolError, match="this MCP server"): - kill_pane(ConcreteEngine(), "%9") + kill_pane(MockEngine(), "%9") def test_respawn_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: @@ -317,14 +317,14 @@ def test_respawn_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") with pytest.raises(ToolError, match="this MCP server"): - respawn_pane(ConcreteEngine(), "%9") + respawn_pane(MockEngine(), "%9") def test_kill_pane_allows_other_pane(monkeypatch: pytest.MonkeyPatch) -> None: """A different pane is not the caller, so it is not refused.""" monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") - assert kill_pane(ConcreteEngine(), "%1") is None + assert kill_pane(MockEngine(), "%1") is None def test_self_kill_refusals_live( @@ -374,7 +374,7 @@ def test_op_kill_pane_is_guarded(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") server = build_async_server( - SyncToAsyncEngine(ConcreteEngine()), + SyncToAsyncEngine(MockEngine()), events="off", expose_operations=True, safety_level="destructive", # op_kill_* is destructive-tier @@ -399,7 +399,7 @@ class Pathed(SyncToAsyncEngine): server_args = ("-S", "/tmp/explicit-socket") async def main() -> str | None: - return await conservative_socket(Pathed(ConcreteEngine()), None) + return await conservative_socket(Pathed(MockEngine()), None) assert asyncio.run(main()) == "/tmp/explicit-socket" diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index de775d0ed..7b95ad244 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -593,12 +593,12 @@ async def main() -> t.Any: def test_no_event_tools_without_a_stream() -> None: """A non-streaming engine registers no event tools, even when asked.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine from libtmux.experimental.mcp.fastmcp_adapter import build_async_server from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine server = build_async_server( - SyncToAsyncEngine(ConcreteEngine()), + SyncToAsyncEngine(MockEngine()), events="both", include_operations=False, include_plan_tools=False, diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py index 2fd5ffd43..baea9b7e8 100644 --- a/tests/experimental/mcp/test_fastmcp_adapter.py +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -3,7 +3,7 @@ Proves the framework-agnostic projection actually drives fastmcp: the curated vocabulary registers as typed tools (engine bound out of the schema, safety -> annotations), and an in-process client can list and call them -- offline against -the ``ConcreteEngine`` and live against a real tmux server. Skipped entirely when +the ``MockEngine`` and live against a real tmux server. Skipped entirely when the ``mcp`` extra (fastmcp) is not installed. """ @@ -14,7 +14,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server from libtmux.experimental.ops import NewSession from libtmux.experimental.ops.serialize import operation_to_dict @@ -28,7 +28,7 @@ def test_adapter_registers_typed_tools() -> None: """The curated vocabulary appears as typed tools with safety annotations.""" # destructive tier so the kill_* tools this asserts on are visible - server = build_server(ConcreteEngine(), safety_level="destructive") + server = build_server(MockEngine(), safety_level="destructive") async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -59,7 +59,7 @@ async def main() -> t.Any: def test_adapter_calls_tool_offline() -> None: """Calling a tool through the in-process client returns structured output.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -96,7 +96,7 @@ async def main() -> str | None: def test_adapter_operations_hidden_by_default() -> None: """Per-operation tools are registered but hidden; plan tools stay visible.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -116,7 +116,7 @@ def test_adapter_exposes_per_op_tools() -> None: """``expose_operations`` reveals one typed ``op_`` per operation.""" # destructive tier so the destructive op_kill_* tools are visible too server = build_server( - ConcreteEngine(), + MockEngine(), expose_operations=True, safety_level="destructive", ) @@ -141,7 +141,7 @@ async def main() -> t.Any: def test_adapter_per_op_call_offline() -> None: """A per-op tool builds + runs its operation, returning the serialized result.""" - server = build_server(ConcreteEngine(), expose_operations=True) + server = build_server(MockEngine(), expose_operations=True) async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -154,7 +154,7 @@ async def main() -> t.Any: def test_adapter_plan_tools_offline() -> None: """preview/execute/result_schema drive a serialized plan with forward refs.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) operations = [operation_to_dict(NewSession(session_name="dev", capture_panes=True))] async def main() -> tuple[t.Any, t.Any, t.Any]: @@ -175,7 +175,7 @@ async def main() -> tuple[t.Any, t.Any, t.Any]: def test_adapter_build_workspace_offline() -> None: """The workspace tool builds a declarative spec in one call (preflight off).""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) spec = { "session_name": "ws", "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 8adfab6a3..5af9a1440 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -3,12 +3,12 @@ Exercises descriptor generation from the operation registry, agent target resolution, plan preview/execute with forward-ref bindings, result-schema introspection, and the build_workspace tool -- all against the in-memory -``ConcreteEngine`` so the projection is provably correct offline. +``MockEngine`` so the projection is provably correct offline. """ from __future__ import annotations -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.mcp import ( OperationToolRegistry, build_workspace, @@ -126,7 +126,7 @@ def test_execute_plan_returns_bindings() -> None: plan = LazyPlan() session = plan.add(NewSession(session_name="dev", capture_panes=True)) plan.add(SendKeys(target=session.pane, keys="vim", enter=True)) - outcome = execute_plan(plan, ConcreteEngine()) + outcome = execute_plan(plan, MockEngine()) assert outcome.ok assert outcome.bindings["0"].startswith("$") assert outcome.bindings["0:pane"].startswith("%") @@ -151,7 +151,7 @@ def test_build_workspace_tool_offline() -> None: "session_name": "dev", "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], }, - ConcreteEngine(), + MockEngine(), preflight=False, ) assert outcome.ok diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py index 175737a83..40592baa3 100644 --- a/tests/experimental/mcp/test_prompts.py +++ b/tests/experimental/mcp/test_prompts.py @@ -7,7 +7,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine fastmcp = pytest.importorskip("fastmcp") @@ -32,7 +32,7 @@ def test_sync_server_omits_wait_for_output_prompts() -> None: """The sync server lacks wait_for_output, so those recipe prompts are gated off.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> set[str]: async with fastmcp.Client(server) as client: diff --git a/tests/experimental/mcp/test_relative_special_guard.py b/tests/experimental/mcp/test_relative_special_guard.py index 152b68a51..7b7400a89 100644 --- a/tests/experimental/mcp/test_relative_special_guard.py +++ b/tests/experimental/mcp/test_relative_special_guard.py @@ -13,7 +13,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.vocabulary import ( break_pane, capture_pane, @@ -42,25 +42,25 @@ def test_grep_rejects_relative_special(token: str) -> None: """grep_pane with a directional special target raises a targeted hint.""" with pytest.raises(ToolError, match="control-mode client"): - grep_pane(ConcreteEngine(capture_lines=("x",)), token, "x") + grep_pane(MockEngine(capture_lines=("x",)), token, "x") def test_capture_rejects_relative_special() -> None: """capture_pane rejects a directional special target.""" with pytest.raises(ToolError, match="control-mode client"): - capture_pane(ConcreteEngine(), "{down-of}") + capture_pane(MockEngine(), "{down-of}") def test_send_rejects_relative_special() -> None: """send_input rejects a directional special target.""" with pytest.raises(ToolError, match="control-mode client"): - send_input(ConcreteEngine(), "{left-of}", "ls") + send_input(MockEngine(), "{left-of}", "ls") @pytest.mark.parametrize("token", ["{marked}", "{last}"]) def test_anchor_specials_pass_through(token: str) -> None: """Anchor special targets are not rejected (real tmux semantics).""" - engine = ConcreteEngine(capture_lines=("hi",)) + engine = MockEngine(capture_lines=("hi",)) assert capture_pane(engine, token).lines == ("hi",) @@ -90,13 +90,13 @@ def test_anchor_specials_pass_through(token: str) -> None: def test_mutating_tools_reject_relative_special(call: t.Any) -> None: """Destructive/mutating pane tools reject a relative special target too.""" with pytest.raises(ToolError, match="control-mode client"): - call(ConcreteEngine()) + call(MockEngine()) def test_grep_pane_invalid_regex_hint() -> None: """An invalid search regex is surfaced as a targeted hint, not a raw re.error.""" with pytest.raises(ToolError, match="invalid search pattern"): - grep_pane(ConcreteEngine(capture_lines=("x",)), "%1", "[unclosed") + grep_pane(MockEngine(capture_lines=("x",)), "%1", "[unclosed") def test_capture_relative_pane_resolves_concrete_live(session: Session) -> None: diff --git a/tests/experimental/mcp/test_resources.py b/tests/experimental/mcp/test_resources.py index 8f5303939..d72bcb4a6 100644 --- a/tests/experimental/mcp/test_resources.py +++ b/tests/experimental/mcp/test_resources.py @@ -8,7 +8,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine fastmcp = pytest.importorskip("fastmcp") @@ -25,7 +25,7 @@ def _text(contents: t.Any) -> str: def test_resources_read_offline_returns_json() -> None: """The sessions resource is registered and returns a JSON array.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> t.Any: async with fastmcp.Client(server) as client: diff --git a/tests/experimental/mcp/test_safety_gate.py b/tests/experimental/mcp/test_safety_gate.py index 5de29f5f0..6dac00d50 100644 --- a/tests/experimental/mcp/test_safety_gate.py +++ b/tests/experimental/mcp/test_safety_gate.py @@ -7,7 +7,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine fastmcp = pytest.importorskip("fastmcp") @@ -18,7 +18,7 @@ def _names_at(level: str, **kwargs: t.Any) -> set[str]: """Return the visible tool names from a server built at *level*.""" async def main() -> set[str]: - server = build_server(ConcreteEngine(), safety_level=level, **kwargs) + server = build_server(MockEngine(), safety_level=level, **kwargs) async with fastmcp.Client(server) as client: return {tool.name for tool in await client.list_tools()} @@ -59,7 +59,7 @@ def test_safety_gate_blocks_destructive_call_at_readonly() -> None: """A destructive tool cannot be successfully called at the readonly tier.""" async def main() -> tuple[str, t.Any]: - server = build_server(ConcreteEngine(), safety_level="readonly") + server = build_server(MockEngine(), safety_level="readonly") async with fastmcp.Client(server) as client: try: result = await client.call_tool("kill_session", {"target": "$1"}) diff --git a/tests/experimental/mcp/test_vocabulary.py b/tests/experimental/mcp/test_vocabulary.py index c0c4ad372..189d4adb7 100644 --- a/tests/experimental/mcp/test_vocabulary.py +++ b/tests/experimental/mcp/test_vocabulary.py @@ -1,6 +1,6 @@ """The curated core vocabulary -- intuitive named tmux tools. -Pure tests run the vocabulary against the in-memory ``ConcreteEngine`` (no tmux); +Pure tests run the vocabulary against the in-memory ``MockEngine`` (no tmux); a live test drives a real tmux server end to end (create -> window -> split -> send -> capture -> rename -> kill) over the subprocess engine. """ @@ -9,7 +9,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp import ( capture_pane, create_session, @@ -34,7 +34,7 @@ def test_create_session_returns_typed_result() -> None: """create_session yields a typed result with the captured first pane id.""" - result = create_session(ConcreteEngine(), name="dev") + result = create_session(MockEngine(), name="dev") assert result.session_id == "$1" assert result.name == "dev" assert result.first_window_id == "@1" @@ -43,7 +43,7 @@ def test_create_session_returns_typed_result() -> None: def test_create_window_then_split() -> None: """create_window captures a first pane id that split_pane can target.""" - engine = ConcreteEngine() + engine = MockEngine() session = create_session(engine, name="dev") window = create_window(engine, session.session_id, name="logs") assert window.window_id.startswith("@") @@ -54,7 +54,7 @@ def test_create_window_then_split() -> None: def test_new_pane_creates_floating_pane() -> None: """new_pane creates a floating pane and returns its id (in-memory).""" - engine = ConcreteEngine() + engine = MockEngine() session = create_session(engine, name="dev") pane = new_pane(engine, session.first_pane_id or "%1", width=80, height=20) assert pane.pane_id.startswith("%") @@ -62,18 +62,18 @@ def test_new_pane_creates_floating_pane() -> None: def test_send_input_is_fire_and_forget() -> None: """send_input runs without returning a value (and without raising).""" - send_input(ConcreteEngine(), "%1", "echo hi", enter=True) + send_input(MockEngine(), "%1", "echo hi", enter=True) def test_capture_pane_returns_lines() -> None: """capture_pane surfaces the pane's lines.""" - engine = ConcreteEngine(capture_lines=("line-1", "line-2")) + engine = MockEngine(capture_lines=("line-1", "line-2")) assert capture_pane(engine, "%1").lines == ("line-1", "line-2") def test_list_tools_return_listings() -> None: """The list_* tools return a Listing of format rows.""" - engine = ConcreteEngine() + engine = MockEngine() assert isinstance(list_sessions(engine).rows, tuple) assert isinstance(list_windows(engine).rows, tuple) assert isinstance(list_panes(engine).rows, tuple) @@ -81,7 +81,7 @@ def test_list_tools_return_listings() -> None: def test_target_accepts_string_or_typed() -> None: """A vocabulary target may be a string or an already-typed Target.""" - engine = ConcreteEngine() + engine = MockEngine() assert create_window(engine, "$1").window_id.startswith("@") assert create_window(engine, SessionId("$1")).window_id.startswith("@") diff --git a/tests/experimental/mcp/test_vocabulary_extended.py b/tests/experimental/mcp/test_vocabulary_extended.py index 81929265c..9b36b4444 100644 --- a/tests/experimental/mcp/test_vocabulary_extended.py +++ b/tests/experimental/mcp/test_vocabulary_extended.py @@ -1,6 +1,6 @@ """Extended vocabulary -- new verbs, conveniences, the async surface, the bridge. -Pure tests run against the in-memory ``ConcreteEngine`` and the pure geometry +Pure tests run against the in-memory ``MockEngine`` and the pure geometry helpers (no tmux); live tests drive a real tmux server for the geometry-resolved conveniences (``resolve_relative_pane`` / ``find_pane_by_position`` / directional ``select_pane``) that only mean something against a real layout. @@ -13,7 +13,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.vocabulary import ( PaneRef, acreate_session, @@ -103,7 +103,7 @@ def test_corner_pane_uses_edge_predicates() -> None: # --------------------------------------------------------------------------- # def test_synced_twin_runs_over_sync_engine() -> None: """A synced twin drives its async source over a plain sync engine.""" - result = create_session(ConcreteEngine(), name="dev") # create_session is a twin + result = create_session(MockEngine(), name="dev") # create_session is a twin assert result.session_id == "$1" @@ -134,25 +134,25 @@ async def tool(engine: t.Any, value: int) -> int: # --------------------------------------------------------------------------- # def test_grep_pane_filters_lines() -> None: """grep_pane returns only the captured lines matching the pattern.""" - engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + engine = MockEngine(capture_lines=("foo", "bar baz", "foobar")) assert grep_pane(engine, "%1", "foo").lines == ("foo", "foobar") def test_grep_pane_ignore_case() -> None: """grep_pane honours the ignore_case flag.""" - engine = ConcreteEngine(capture_lines=("FOO", "bar")) + engine = MockEngine(capture_lines=("FOO", "bar")) assert grep_pane(engine, "%1", "foo", ignore_case=True).lines == ("FOO",) def test_capture_active_pane_needs_no_target() -> None: """capture_active_pane captures with no explicit target.""" - engine = ConcreteEngine(capture_lines=("hello",)) + engine = MockEngine(capture_lines=("hello",)) assert capture_active_pane(engine).lines == ("hello",) def test_resize_and_run_tmux_offline() -> None: """resize_pane is fire-and-forget; run_tmux returns a raw outcome.""" - engine = ConcreteEngine() + engine = MockEngine() assert resize_pane(engine, "%1", width=80) is None raw = run_tmux(engine, ["list-sessions"]) assert raw.ok and raw.returncode == 0 @@ -160,12 +160,12 @@ def test_resize_and_run_tmux_offline() -> None: def test_has_session_returns_bool() -> None: """has_session answers an existence query as a bool.""" - assert has_session(ConcreteEngine(), "$1") is True + assert has_session(MockEngine(), "$1") is True def test_geometry_tools_return_paneref_offline() -> None: """Geometry-resolved tools return a PaneRef even with nothing to resolve.""" - engine = ConcreteEngine() + engine = MockEngine() assert isinstance(resolve_relative_pane(engine, "right", "%1"), PaneRef) assert isinstance(select_pane(engine, "%1", direction="left"), PaneRef) @@ -177,7 +177,7 @@ def test_async_surface_over_wrapped_engine() -> None: """The a-prefixed tools run over an async engine (sync engine wrapped).""" async def main() -> tuple[str, tuple[str, ...]]: - engine = SyncToAsyncEngine(ConcreteEngine(capture_lines=("x", "y"))) + engine = SyncToAsyncEngine(MockEngine(capture_lines=("x", "y"))) session = await acreate_session(engine, name="dev") grep = await agrep_pane(engine, "%1", "x") return session.session_id, grep.lines diff --git a/tests/experimental/objects/test_matrix_complete.py b/tests/experimental/objects/test_matrix_complete.py index f20495415..bbee5f973 100644 --- a/tests/experimental/objects/test_matrix_complete.py +++ b/tests/experimental/objects/test_matrix_complete.py @@ -4,7 +4,7 @@ import asyncio -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.objects import ( AsyncClient, AsyncServer, @@ -24,7 +24,7 @@ def test_lazy_server_session_window_plan() -> None: window.split() assert len(plan) == 3 # new-session, new-window, split-window - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.ok assert [r.created_id for r in outcome.results] == ["$1", "@1", "%1"] @@ -33,7 +33,7 @@ def test_async_server_navigation() -> None: """AsyncServer->AsyncSession->AsyncWindow navigation via await.""" async def main() -> str: - server = AsyncServer(AsyncConcreteEngine()) + server = AsyncServer(AsyncMockEngine()) session = await server.new_session(name="work") window = await session.new_window() pane = await window.split() @@ -44,7 +44,7 @@ async def main() -> str: def test_eager_client_methods() -> None: """EagerClient detach/refresh/switch_to return successful results.""" - client = EagerClient(ConcreteEngine(), "/dev/pts/3") + client = EagerClient(MockEngine(), "/dev/pts/3") assert client.refresh().ok assert client.switch_to("$1").ok assert client.detach().ok @@ -56,14 +56,14 @@ def test_lazy_client_records() -> None: client = LazyClient(plan, "/dev/pts/3") client.refresh().switch_to("$1") assert [op.kind for op in plan] == ["refresh_client", "switch_client"] - assert plan.execute(ConcreteEngine()).ok + assert plan.execute(MockEngine()).ok def test_async_client() -> None: """AsyncClient mirrors the eager client via await.""" async def main() -> bool: - client = AsyncClient(AsyncConcreteEngine(), "/dev/pts/3") + client = AsyncClient(AsyncMockEngine(), "/dev/pts/3") return (await client.refresh()).ok assert asyncio.run(main()) diff --git a/tests/experimental/objects/test_object_matrix.py b/tests/experimental/objects/test_object_matrix.py index 7956da80d..1e5dc6e4e 100644 --- a/tests/experimental/objects/test_object_matrix.py +++ b/tests/experimental/objects/test_object_matrix.py @@ -5,7 +5,7 @@ import asyncio import typing as t -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.objects import ( AsyncPane, AsyncWindow, @@ -22,8 +22,8 @@ def test_eager_full_navigation_offline() -> None: - """Eager Server->Session->Window->Pane navigation via the concrete engine.""" - server = EagerServer(ConcreteEngine()) + """Eager Server->Session->Window->Pane navigation via the mock engine.""" + server = EagerServer(MockEngine()) session = server.new_session(name="work") assert session.session_id == "$1" window = session.new_window(name="build") @@ -35,7 +35,7 @@ def test_eager_full_navigation_offline() -> None: def test_eager_window_methods() -> None: """EagerWindow rename/select_layout/kill return successful results.""" - window = EagerWindow(ConcreteEngine(), "@1") + window = EagerWindow(MockEngine(), "@1") assert window.rename("x").ok assert window.select_layout("tiled").ok assert window.kill().ok @@ -49,7 +49,7 @@ def test_lazy_window_records_and_executes() -> None: window.rename("build") assert len(plan) == 2 - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.ok assert outcome.results[0].created_id == "%1" @@ -58,7 +58,7 @@ def test_async_window_and_pane() -> None: """Async objects mirror the eager ones via await.""" async def main() -> tuple[str, bool, bool]: - window = AsyncWindow(AsyncConcreteEngine(), "@1") + window = AsyncWindow(AsyncMockEngine(), "@1") pane = await window.split() assert isinstance(pane, AsyncPane) sent = await pane.send_keys("echo hi", enter=True) diff --git a/tests/experimental/objects/test_pane_object.py b/tests/experimental/objects/test_pane_object.py index 448ff7c94..14f0d58f0 100644 --- a/tests/experimental/objects/test_pane_object.py +++ b/tests/experimental/objects/test_pane_object.py @@ -2,7 +2,7 @@ from __future__ import annotations -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.objects import EagerPane, LazyPane from libtmux.experimental.ops import LazyPlan from libtmux.experimental.ops._types import PaneId @@ -11,7 +11,7 @@ def test_eager_split_returns_live_pane() -> None: """EagerPane.split executes now and returns a live EagerPane object.""" - pane = EagerPane(ConcreteEngine(), "%0") + pane = EagerPane(MockEngine(), "%0") child = pane.split(horizontal=True) assert isinstance(child, EagerPane) assert child.pane_id == "%1" @@ -19,7 +19,7 @@ def test_eager_split_returns_live_pane() -> None: def test_eager_capture_and_send() -> None: """Eager capture/send-keys return typed results.""" - engine = ConcreteEngine(capture_lines=("a", "b")) + engine = MockEngine(capture_lines=("a", "b")) pane = EagerPane(engine, "%1") assert pane.capture().lines == ("a", "b") assert pane.send_keys("echo hi", enter=True).ok @@ -40,7 +40,7 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: root = LazyPane(plan, PaneId("%0")) root.split().send_keys("vim", enter=True) - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) first = outcome.results[0] assert isinstance(first, SplitWindowResult) @@ -50,7 +50,7 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: def test_eager_new_pane_returns_live_pane() -> None: """EagerPane.new_pane creates a floating pane and returns a live object.""" - pane = EagerPane(ConcreteEngine(), "%0") + pane = EagerPane(MockEngine(), "%0") floating = pane.new_pane(width=80, height=20, x=5, y=3) assert isinstance(floating, EagerPane) assert floating.pane_id == "%1" @@ -78,11 +78,11 @@ def test_async_new_pane_returns_live_pane() -> None: """AsyncPane.new_pane creates a floating pane and returns a live object.""" import asyncio - from libtmux.experimental.engines import AsyncConcreteEngine + from libtmux.experimental.engines import AsyncMockEngine from libtmux.experimental.objects import AsyncPane async def main() -> str: - pane = AsyncPane(AsyncConcreteEngine(), "%0") + pane = AsyncPane(AsyncMockEngine(), "%0") floating = await pane.new_pane(width=80, height=20) return floating.pane_id @@ -91,7 +91,7 @@ async def main() -> str: def test_same_operation_backs_both_objects() -> None: """Eager and lazy objects render the identical underlying operation argv.""" - eager_engine = ConcreteEngine() + eager_engine = MockEngine() eager = EagerPane(eager_engine, "%0") # Capture the eager split's rendered argv via the engine-independent op. plan = LazyPlan() diff --git a/tests/experimental/ops/test_ack_ops.py b/tests/experimental/ops/test_ack_ops.py index 3e03d8b54..e1981f012 100644 --- a/tests/experimental/ops/test_ack_ops.py +++ b/tests/experimental/ops/test_ack_ops.py @@ -6,7 +6,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( KillPane, KillWindow, @@ -58,7 +58,7 @@ def test_no_output_ops_return_ack( expected_argv: tuple[str, ...], ) -> None: """No-output operations render correctly and yield an AckResult.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert type(result) is AckResult assert result.argv == expected_argv assert result.ok diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 1ac1af278..bf1e59b2c 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -142,9 +142,9 @@ def test_fold_keeps_creation_ops_unfolded() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) # not chainable plan.add(SendKeys(target=pane, keys="vim")) # chainable, targets new pane plan.add(RenameWindow(target=WindowId("@1"), name="x")) # chainable - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine - outcome = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + outcome = plan.execute(MockEngine(), planner=FoldingPlanner()) # split resolved the pane id; the send-keys folded with rename, retargeted assert outcome.results[1].argv[:3] == ("send-keys", "-t", "%1") diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py index 524920ba4..b0c73892e 100644 --- a/tests/experimental/ops/test_execute.py +++ b/tests/experimental/ops/test_execute.py @@ -13,8 +13,8 @@ from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine from libtmux.experimental.engines.base import SupportsTmuxVersion -from libtmux.experimental.engines.concrete import ConcreteEngine from libtmux.experimental.engines.control_mode import ControlModeEngine +from libtmux.experimental.engines.mock import MockEngine from libtmux.experimental.engines.subprocess import SubprocessEngine from libtmux.experimental.ops import SendKeys, SplitWindow, arun, run from libtmux.experimental.ops._types import PaneId, WindowId @@ -157,7 +157,7 @@ class _CapabilityCase(t.NamedTuple): _CapabilityCase("subprocess", SubprocessEngine, True), _CapabilityCase("control_mode", ControlModeEngine, True), _CapabilityCase("async_control_mode", AsyncControlModeEngine, True), - _CapabilityCase("concrete", ConcreteEngine, False), + _CapabilityCase("mock", MockEngine, False), ) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 61055c378..cc288e3ba 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -7,7 +7,7 @@ import pytest -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import ( BreakPane, @@ -48,7 +48,7 @@ def test_plan_resolves_forward_ref() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) plan.add(SendKeys(target=pane, keys="vim", enter=True)) - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.bindings == {0: "%1"} assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") @@ -59,7 +59,7 @@ def test_plan_execute_auto_resolves_engine_version() -> None: """plan.execute() resolves the engine version so folded renders are gated.""" from libtmux.experimental.ops import FoldingPlanner, RespawnPane - class VersionedConcreteEngine(ConcreteEngine): + class VersionedMockEngine(MockEngine): def tmux_version(self) -> str | None: return "2.9" @@ -67,7 +67,7 @@ def tmux_version(self) -> str | None: plan.add(RespawnPane(target=PaneId("%1"), environment={"E": "1"})) plan.add(RespawnPane(target=PaneId("%2"), environment={"E": "2"})) - outcome = plan.execute(VersionedConcreteEngine(), planner=FoldingPlanner()) + outcome = plan.execute(VersionedMockEngine(), planner=FoldingPlanner()) # -e is gated at tmux 3.0; on the engine's resolved 2.9 it is dropped even # from the folded (rendered-in-_drive) dispatch. @@ -101,7 +101,7 @@ def test_plan_resolves_src_target(test_id: str, op: Operation[t.Any]) -> None: plan = LazyPlan() plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 plan.add(op) - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.ok assert outcome.results[1].argv[-2:] == ("-s", "%1") @@ -134,7 +134,7 @@ def test_marked_plan_resolves_decorate_src_target( plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 (own dispatch) plan.add(SplitWindow(target=WindowId("@1"))) # slot 1 -> the marked-fold creator plan.add(op) # slot 2 -> decorate: target {marked}, src_target -> slot 0 - outcome = plan.execute(ConcreteEngine(), planner=MarkedPlanner()) + outcome = plan.execute(MockEngine(), planner=MarkedPlanner()) assert outcome.ok assert outcome.results[2].argv[-2:] == ("-s", "%1") @@ -145,7 +145,7 @@ def test_plan_aexecute_matches_execute() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) plan.add(SendKeys(target=pane, keys="vim", enter=True)) - outcome = asyncio.run(plan.aexecute(AsyncConcreteEngine())) + outcome = asyncio.run(plan.aexecute(AsyncMockEngine())) assert outcome.bindings == {0: "%1"} assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") @@ -159,7 +159,7 @@ def test_execute_on_step_reports_each_step() -> None: reports: list[StepReport] = [] outcome = plan.execute( - ConcreteEngine(), + MockEngine(), planner=SequentialPlanner(), on_step=reports.append, ) @@ -183,7 +183,7 @@ def test_aexecute_on_step_matches_execute() -> None: sync_steps: list[tuple[int, ...]] = [] plan.execute( - ConcreteEngine(), + MockEngine(), on_step=lambda report: sync_steps.append(report.step.indices), ) @@ -192,7 +192,7 @@ def test_aexecute_on_step_matches_execute() -> None: async def collect(report: StepReport) -> None: async_steps.append(report.step.indices) - asyncio.run(plan.aexecute(AsyncConcreteEngine(), on_step=collect)) + asyncio.run(plan.aexecute(AsyncMockEngine(), on_step=collect)) assert async_steps == sync_steps == [(0,), (1,)] @@ -213,7 +213,7 @@ def test_plan_unresolvable_ref_fails_closed() -> None: typed = plan.add(SendKeys(target=PaneId("%1"), keys="x")) # creates no id plan.add(SendKeys(target=typed, keys="y")) with pytest.raises(ForwardCaptureError, match="captured no id") as exc_info: - plan.execute(ConcreteEngine()) + plan.execute(MockEngine()) assert exc_info.value.slot == 0 # points at the non-capturing creator # ForwardCaptureError stays an OperationError, so broad handlers keep working assert isinstance(exc_info.value, OperationError) @@ -266,7 +266,7 @@ def test_astream_yields_step_then_plan_done() -> None: plan = _split_then_send() async def drain() -> list[object]: - return [event async for event in plan.astream(AsyncConcreteEngine())] + return [event async for event in plan.astream(AsyncMockEngine())] events = asyncio.run(drain()) assert [type(e).__name__ for e in events] == ["StepDone", "StepDone", "PlanDone"] @@ -281,8 +281,8 @@ def test_astream_last_result_matches_aexecute() -> None: from libtmux.experimental.ops import PlanDone async def both() -> tuple[bool, bool]: - streamed = [e async for e in _split_then_send().astream(AsyncConcreteEngine())] - direct = await _split_then_send().aexecute(AsyncConcreteEngine()) + streamed = [e async for e in _split_then_send().astream(AsyncMockEngine())] + direct = await _split_then_send().aexecute(AsyncMockEngine()) last = streamed[-1] assert isinstance(last, PlanDone) return last.result.ok, direct.ok diff --git a/tests/experimental/ops/test_read_ops.py b/tests/experimental/ops/test_read_ops.py index 3a3ebb5eb..94414d7c8 100644 --- a/tests/experimental/ops/test_read_ops.py +++ b/tests/experimental/ops/test_read_ops.py @@ -4,7 +4,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( ListPanes, ListSessions, @@ -72,7 +72,7 @@ def test_list_result_serialization_round_trip() -> None: def test_empty_output_yields_empty_views() -> None: """No panes -> empty rows, empty snapshot, no error.""" - result = run(ListPanes(), ConcreteEngine(), version="3.6a") + result = run(ListPanes(), MockEngine(), version="3.6a") assert result.rows == () assert result.server.sessions == () assert result.ok diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index 28b930bd4..9146a2ab8 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -6,7 +6,7 @@ import pytest -from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.mock import MockEngine from libtmux.experimental.fluent import PlanBuilder, plan from libtmux.experimental.query import ForwardPaneRef @@ -63,7 +63,7 @@ def test_builder_runs_offline(case: _BuildCase) -> None: """The build resolves forward refs and folds over the in-memory engine.""" p = plan() case.build(p) - assert p.run(ConcreteEngine()).ok + assert p.run(MockEngine()).ok def test_window_pane_is_forward_handle() -> None: @@ -150,7 +150,7 @@ def test_sleep_runs_offline() -> None: pane.do(lambda c: c.send_keys("vim")) p.sleep(0.0) pane.split().do(lambda c: c.send_keys("htop")) - assert p.run(ConcreteEngine()).ok + assert p.run(MockEngine()).ok def test_find_or_create_session_records_a_conditional_create() -> None: diff --git a/tests/experimental/test_freeze.py b/tests/experimental/test_freeze.py index 54651c71a..90f6d59c5 100644 --- a/tests/experimental/test_freeze.py +++ b/tests/experimental/test_freeze.py @@ -13,7 +13,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine from libtmux.experimental.models.snapshots import ServerSnapshot from libtmux.experimental.workspace.freeze import SHELLS, afreeze_server, freeze @@ -187,7 +187,7 @@ def test_freeze_round_trips_into_a_buildable_workspace() -> None: ) ws = freeze(server) assert ws.compile().operations[0].kind == "new_session" - assert ws.build(ConcreteEngine(), preflight=False).ok + assert ws.build(MockEngine(), preflight=False).ok def test_afreeze_server_captures_live_tree(session: Session) -> None: diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index a3d9c3a41..d41256cfe 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -147,9 +147,9 @@ def test_query_is_immutable() -> None: def test_empty_engine_source() -> None: """A query resolves against an engine; the in-memory engine has no panes.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine - assert panes().all(ConcreteEngine()) == () + assert panes().all(MockEngine()) == () def test_commands_to_plan_builds_one_op_per_pane() -> None: From 8a3fe226f9a084b5a2a205df6a21a873dbf29c89 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:39:35 -0500 Subject: [PATCH 154/223] Engines(refactor[connection]): Hold a ServerConnection why: Every engine re-derived the same two things before it could dispatch: which tmux binary to exec, and which tmux server to point at. Five copies of the -L/-S/-f/-2/-8 flag construction, three copies of the memoized shutil.which + `tmux -V` probe, and one drifted copy in control_mode that called shutil.which unguarded and unmemoized on every connect. what: - Add engines/connection.py with a frozen ServerConnection value object owning the connection flags, memoized binary resolution, memoized version probe, and argv assembly - ServerConnection.from_server() is the single mirror of Server.cmd()'s flag construction; every engine's for_server() is now built on it - SubprocessEngine, AsyncSubprocessEngine, ControlModeEngine, AsyncControlModeEngine and ImsgEngine hold a connection instead of re-deriving one; tmux_bin/server_args stay as read-only properties - Export ServerConnection from libtmux.experimental.engines --- src/libtmux/experimental/engines/__init__.py | 2 + .../engines/async_control_mode.py | 52 ++--- src/libtmux/experimental/engines/asyncio.py | 63 ++--- .../experimental/engines/connection.py | 216 ++++++++++++++++++ .../experimental/engines/control_mode.py | 52 ++--- src/libtmux/experimental/engines/imsg/base.py | 37 +-- .../experimental/engines/subprocess.py | 70 ++---- 7 files changed, 322 insertions(+), 170 deletions(-) create mode 100644 src/libtmux/experimental/engines/connection.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 2ad70542c..0d197334f 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -26,6 +26,7 @@ SupportsTmuxVersion, TmuxEngine, ) +from libtmux.experimental.engines.connection import ServerConnection from libtmux.experimental.engines.control_mode import ( ControlModeEngine, ControlModeError, @@ -55,6 +56,7 @@ "EngineSpec", "ImsgEngine", "MockEngine", + "ServerConnection", "SubprocessEngine", "SupportsTmuxVersion", "TmuxEngine", diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 5e8a65096..3e622d9a9 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -35,13 +35,12 @@ import asyncio import collections import contextlib -import shutil import typing as t from dataclasses import dataclass, field from libtmux import exc -from libtmux.common import get_version from libtmux.experimental.engines.base import render_control_line +from libtmux.experimental.engines.connection import ServerConnection from libtmux.experimental.engines.control_mode import ( ControlModeError, ControlModeParser, @@ -160,7 +159,7 @@ class AsyncControlModeEngine: Parameters ---------- tmux_bin : str or None - The tmux binary; resolved via :func:`shutil.which` when ``None``. + The tmux binary; resolved from ``$PATH`` when ``None``. server_args : Sequence[str] Connection flags inserted before ``-C``. timeout : float @@ -182,8 +181,7 @@ def __init__( timeout: float = _DEFAULT_TIMEOUT, event_queue_size: int = 4096, ) -> None: - self.tmux_bin = tmux_bin - self.server_args = tuple(server_args) + self._conn = ServerConnection.of(tmux_bin, server_args) self.timeout = timeout self._parser = ControlModeParser() self._pending: collections.deque[_PendingCommand] = collections.deque() @@ -206,18 +204,30 @@ def __init__( self._connected = asyncio.Event() self._spawn_error: BaseException | None = None + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before ``-C``.""" + return self._conn.args + def tmux_version(self) -> str | None: - """Report the connected server's tmux version (``tmux -V``). + """Report the connected server's tmux version (``tmux -V``), memoized. Implements :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so version-gated operations render correctly over control mode; in-memory engines omit it and resolution assumes latest. """ - try: - return str(get_version(self.tmux_bin)) - except exc.LibTmuxException: - return None + return self._conn.tmux_version() def add_subscription(self, spec: str) -> None: """Record a desired ``refresh-client -B`` subscription (idempotent). @@ -319,10 +329,7 @@ async def _spawn(self) -> None: if old is not None and old.returncode is None: with contextlib.suppress(ProcessLookupError): old.terminate() - tmux_bin = self.tmux_bin or shutil.which("tmux") - if tmux_bin is None: - raise exc.TmuxCommandNotFound - cmd = [tmux_bin, *self.server_args, "-C"] + cmd = self._conn.argv("-C") try: proc = await asyncio.create_subprocess_exec( *cmd, @@ -794,20 +801,9 @@ def _fail_pending(self, error: BaseException) -> None: @classmethod def for_server(cls, server: t.Any, **kwargs: t.Any) -> AsyncControlModeEngine: """Build an async control-mode engine bound to a live server's socket.""" - server_args: list[str] = [] - if getattr(server, "socket_name", None): - server_args.append(f"-L{server.socket_name}") - if getattr(server, "socket_path", None): - server_args.append(f"-S{server.socket_path}") - if getattr(server, "config_file", None): - server_args.append(f"-f{server.config_file}") - colors = getattr(server, "colors", None) - if colors == 256: - server_args.append("-2") - elif colors == 88: - server_args.append("-8") + conn = ServerConnection.from_server(server) return cls( - tmux_bin=getattr(server, "tmux_bin", None), - server_args=server_args, + tmux_bin=conn.tmux_bin, + server_args=conn.args, **kwargs, ) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py index e5717a6b5..69206db89 100644 --- a/src/libtmux/experimental/engines/asyncio.py +++ b/src/libtmux/experimental/engines/asyncio.py @@ -12,12 +12,11 @@ import asyncio import contextlib -import shutil import typing as t from libtmux import exc -from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.engines.connection import ServerConnection if t.TYPE_CHECKING: import pathlib @@ -32,7 +31,7 @@ class AsyncSubprocessEngine: Parameters ---------- tmux_bin : str or pathlib.Path or None - The tmux binary; resolved via :func:`shutil.which` when ``None``. + The tmux binary; resolved from ``$PATH`` when ``None``. server_args : Sequence[str] Connection flags inserted before the command. @@ -52,11 +51,22 @@ def __init__( *, server_args: Sequence[str] = (), ) -> None: - self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None - self.server_args = tuple(server_args) - self._resolved_bin: str | None = None - self._tmux_version: str | None = None - self._version_probed = False + self._conn = ServerConnection.of(tmux_bin, server_args) + + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before every tmux subcommand.""" + return self._conn.args def tmux_version(self) -> str | None: """Report this engine's tmux version (``tmux -V``), memoized. @@ -64,29 +74,11 @@ def tmux_version(self) -> str | None: Returns ``None`` when the binary is missing or its version cannot be parsed, so version resolution degrades to "assume latest". """ - if not self._version_probed: - self._version_probed = True - try: - self._tmux_version = str(get_version(self._resolve_bin())) - except exc.LibTmuxException: - self._tmux_version = None - return self._tmux_version - - def _resolve_bin(self) -> str: - """Return the tmux binary path, memoized for the engine instance.""" - if self.tmux_bin is not None: - return self.tmux_bin - if self._resolved_bin is None: - resolved = shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound - self._resolved_bin = resolved - return self._resolved_bin + return self._conn.tmux_version() async def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command asynchronously and return its result.""" - tmux_bin = request.tmux_bin or self._resolve_bin() - cmd = [tmux_bin, *self.server_args, *request.args] + cmd = self._conn.argv(*request.args, tmux_bin=request.tmux_bin) try: process = await asyncio.create_subprocess_exec( @@ -132,16 +124,5 @@ async def run_batch( @classmethod def for_server(cls, server: t.Any) -> AsyncSubprocessEngine: """Build an async engine bound to a live :class:`libtmux.Server`'s socket.""" - server_args: list[str] = [] - if getattr(server, "socket_name", None): - server_args.append(f"-L{server.socket_name}") - if getattr(server, "socket_path", None): - server_args.append(f"-S{server.socket_path}") - if getattr(server, "config_file", None): - server_args.append(f"-f{server.config_file}") - colors = getattr(server, "colors", None) - if colors == 256: - server_args.append("-2") - elif colors == 88: - server_args.append("-8") - return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) + conn = ServerConnection.from_server(server) + return cls(tmux_bin=conn.tmux_bin, server_args=conn.args) diff --git a/src/libtmux/experimental/engines/connection.py b/src/libtmux/experimental/engines/connection.py new file mode 100644 index 000000000..8968b71f8 --- /dev/null +++ b/src/libtmux/experimental/engines/connection.py @@ -0,0 +1,216 @@ +"""The connection an engine talks to: which tmux binary, which tmux server. + +Every real engine -- subprocess, asyncio, control mode, async control mode, imsg +-- needs the same two things before it can dispatch anything: a tmux *binary* to +exec, and the *connection flags* (``-L``/``-S``/``-f``/``-2``/``-8``) that point +at one particular tmux server. :class:`ServerConnection` is that pair, as one +frozen value object, and it is the *only* place either is computed. + +Engines **hold** a connection (``self._conn``) rather than re-deriving one. +:meth:`ServerConnection.resolve_bin` is the single door to a tmux binary path: +it memoizes :func:`shutil.which` and raises +:exc:`~libtmux.exc.TmuxCommandNotFound` when tmux is absent, so an engine cannot +accidentally ship an unguarded, unmemoized ``shutil.which("tmux")`` of its own. +:meth:`ServerConnection.tmux_version` is the matching memoized ``tmux -V`` probe. +""" + +from __future__ import annotations + +import shutil +import typing as t +from dataclasses import dataclass, field + +from libtmux import exc +from libtmux.common import get_version + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + +class _BinaryResolver: + """Memoized tmux-binary resolution and ``tmux -V`` probe. + + Owned by a :class:`ServerConnection`; never constructed by engines. Holding + the (mutable) cache here keeps :class:`ServerConnection` itself a frozen, + comparable value. + """ + + __slots__ = ("_declared", "_resolved", "_version", "_version_probed") + + def __init__(self, tmux_bin: str | None = None) -> None: + self._declared = tmux_bin + self._resolved: str | None = None + self._version: str | None = None + self._version_probed = False + + def resolve(self) -> str: + """Return the tmux binary path, memoized for this connection. + + An explicit binary wins. Otherwise :func:`shutil.which` walks ``$PATH`` + once -- it costs ~50µs and sits on the hot path of every command -- and + the answer is cached. A *failure* is not cached, so a tmux installed + after the miss is picked up. + """ + if self._declared is not None: + return self._declared + if self._resolved is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved = resolved + return self._resolved + + def version(self) -> str | None: + """Return the tmux version string, memoized; ``None`` when unknowable. + + ``None`` (missing binary, unparseable output) lets version resolution + degrade to "assume latest" rather than exploding. + """ + if not self._version_probed: + self._version_probed = True + try: + self._version = str(get_version(self.resolve())) + except exc.LibTmuxException: + self._version = None + return self._version + + +@dataclass(frozen=True) +class ServerConnection: + """Which tmux binary, and which tmux server, an engine talks to. + + Parameters + ---------- + tmux_bin : str or None + An explicit tmux binary. ``None`` means "resolve from ``$PATH``", which + :meth:`resolve_bin` does once and memoizes. + args : tuple[str, ...] + Connection flags placed before the tmux subcommand (e.g. ``("-Lwork",)``). + + Examples + -------- + The default connection targets the ambient tmux server: + + >>> ServerConnection() + ServerConnection(tmux_bin=None, args=()) + + :meth:`from_server` reads the flags off a live :class:`libtmux.Server`, + which is what every engine's ``for_server`` classmethod is built on: + + >>> conn = ServerConnection.from_server(server) + >>> conn.args[0].startswith(("-L", "-S")) + True + >>> conn.tmux_version() == conn.tmux_version() # memoized + True + + It duck-types, so a plain object with the same attributes works too: + + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_name="work", colors=256) + ... ) + ServerConnection(tmux_bin=None, args=('-Lwork', '-2')) + + :meth:`argv` prepends the binary and the flags to a rendered command: + + >>> ServerConnection.of(tmux_bin="tmux", args=("-Lwork",)).argv( + ... "kill-window", "-t", "@1" + ... ) + ['tmux', '-Lwork', 'kill-window', '-t', '@1'] + """ + + tmux_bin: str | None = None + args: tuple[str, ...] = () + _resolver: _BinaryResolver = field( + init=False, + repr=False, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + """Normalize *args* and build the connection's binary resolver.""" + object.__setattr__(self, "args", tuple(self.args)) + object.__setattr__(self, "_resolver", _BinaryResolver(self.tmux_bin)) + + @classmethod + def of( + cls, + tmux_bin: str | pathlib.Path | None = None, + args: Sequence[str] = (), + ) -> ServerConnection: + """Build a connection, stringifying a :class:`pathlib.Path` binary. + + Examples + -------- + >>> import pathlib + >>> ServerConnection.of(pathlib.Path("/usr/bin/tmux")).tmux_bin + '/usr/bin/tmux' + >>> ServerConnection.of(args=["-L", "test"]).args + ('-L', 'test') + """ + return cls( + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + args=tuple(args), + ) + + @classmethod + def from_server(cls, server: t.Any) -> ServerConnection: + """Build the connection a live :class:`libtmux.Server` talks over. + + Mirrors :meth:`libtmux.Server.cmd`'s connection-flag construction, so an + engine built from it reaches the same tmux server as the object API. + + Examples + -------- + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_path="/tmp/s", config_file="/tmp/c") + ... ) + ServerConnection(tmux_bin=None, args=('-S/tmp/s', '-f/tmp/c')) + """ + args: list[str] = [] + if getattr(server, "socket_name", None): + args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + args.append("-2") + elif colors == 88: + args.append("-8") + return cls.of(tmux_bin=getattr(server, "tmux_bin", None), args=args) + + def resolve_bin(self) -> str: + """Return the tmux binary path (memoized). + + Raises :exc:`~libtmux.exc.TmuxCommandNotFound` when tmux is not on + ``$PATH`` and none was declared -- the guard every engine gets for free. + + Examples + -------- + >>> ServerConnection.of(tmux_bin="/usr/bin/tmux").resolve_bin() + '/usr/bin/tmux' + """ + return self._resolver.resolve() + + def tmux_version(self) -> str | None: + """Return this connection's tmux version (memoized), or ``None``. + + Examples + -------- + >>> ServerConnection().tmux_version() is not None + True + """ + return self._resolver.version() + + def argv(self, *args: str, tmux_bin: str | None = None) -> list[str]: + """Render a full command line: binary, connection flags, then *args*. + + *tmux_bin* overrides this connection's binary for one command (a + :class:`~libtmux.experimental.engines.base.CommandRequest` may carry one). + """ + return [tmux_bin or self.resolve_bin(), *self.args, *args] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 2fee21f50..d4e8f3f90 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -20,15 +20,14 @@ import logging import os import selectors -import shutil import subprocess import threading import time import typing as t from libtmux import exc -from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult, render_control_line +from libtmux.experimental.engines.connection import ServerConnection if t.TYPE_CHECKING: import types @@ -161,7 +160,7 @@ class ControlModeEngine: Parameters ---------- tmux_bin : str or None - The tmux binary; resolved via :func:`shutil.which` when ``None``. + The tmux binary; resolved from ``$PATH`` when ``None``. server_args : Sequence[str] Connection flags inserted before ``-C``. timeout : float @@ -180,26 +179,37 @@ def __init__( server_args: Sequence[str] = (), timeout: float = _DEFAULT_TIMEOUT, ) -> None: - self.tmux_bin = tmux_bin - self.server_args = tuple(server_args) + self._conn = ServerConnection.of(tmux_bin, server_args) self.timeout = timeout self._lock = threading.Lock() self._parser = ControlModeParser() self._proc: subprocess.Popen[bytes] | None = None self._selector: selectors.DefaultSelector | None = None + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before ``-C``.""" + return self._conn.args + def tmux_version(self) -> str | None: - """Report the connected server's tmux version (``tmux -V``). + """Report the connected server's tmux version (``tmux -V``), memoized. Implements :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so version-gated operations render correctly over control mode; in-memory engines omit it and resolution assumes latest. """ - try: - return str(get_version(self.tmux_bin)) - except exc.LibTmuxException: - return None + return self._conn.tmux_version() def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" @@ -277,10 +287,7 @@ def _ensure_started(self) -> None: msg = f"tmux -C exited with code {self._proc.returncode}" raise ControlModeError(msg) return - tmux_bin = self.tmux_bin or shutil.which("tmux") - if tmux_bin is None: - raise exc.TmuxCommandNotFound - cmd = [tmux_bin, *self.server_args, "-C"] + cmd = self._conn.argv("-C") try: proc = subprocess.Popen( cmd, @@ -445,21 +452,10 @@ def _read_stderr(self) -> None: @classmethod def for_server(cls, server: t.Any, **kwargs: t.Any) -> ControlModeEngine: """Build a control-mode engine bound to a live server's socket.""" - server_args: list[str] = [] - if getattr(server, "socket_name", None): - server_args.append(f"-L{server.socket_name}") - if getattr(server, "socket_path", None): - server_args.append(f"-S{server.socket_path}") - if getattr(server, "config_file", None): - server_args.append(f"-f{server.config_file}") - colors = getattr(server, "colors", None) - if colors == 256: - server_args.append("-2") - elif colors == 88: - server_args.append("-8") + conn = ServerConnection.from_server(server) return cls( - tmux_bin=getattr(server, "tmux_bin", None), - server_args=server_args, + tmux_bin=conn.tmux_bin, + server_args=conn.args, **kwargs, ) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index ba433a36e..30265cd6a 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -9,13 +9,12 @@ import os import pathlib import selectors -import shutil import socket import typing as t from libtmux import exc -from libtmux.common import get_version from libtmux.experimental.engines.base import CommandRequest, CommandResult +from libtmux.experimental.engines.connection import ServerConnection from libtmux.experimental.engines.imsg.exc import ( ImsgProtocolError, UnsupportedProtocolVersion, @@ -227,9 +226,12 @@ def __init__(self, protocol_version: str | int | None = None) -> None: self.protocol_version = ( str(protocol_version) if protocol_version is not None else None ) - self._resolved_tmux_bin: str | None = None - self._tmux_version: str | None = None - self._version_probed = False + self._conn = ServerConnection() + + @property + def connection(self) -> ServerConnection: + """The tmux binary this engine execs for local/spawning commands.""" + return self._conn def tmux_version(self) -> str | None: """Report this engine's tmux version (``tmux -V``), memoized. @@ -238,17 +240,11 @@ def tmux_version(self) -> str | None: true server version instead of degrading to "assume latest". Returns ``None`` when the binary is missing or its version cannot be parsed. """ - if not self._version_probed: - self._version_probed = True - try: - self._tmux_version = str(get_version(self._resolve_tmux_bin())) - except exc.LibTmuxException: - self._tmux_version = None - return self._tmux_version + return self._conn.tmux_version() def run(self, request: CommandRequest) -> CommandResult: """Execute a tmux command over the server socket.""" - tmux_bin = request.tmux_bin or self._resolve_tmux_bin() + tmux_bin = request.tmux_bin or self._conn.resolve_bin() parsed = self._parse_args(request.args) cmd = [tmux_bin, *parsed.global_args, *parsed.command_argv] @@ -315,21 +311,6 @@ def run(self, request: CommandRequest) -> CommandResult: if sock is not None: sock.close() - def _resolve_tmux_bin(self) -> str: - """Return the tmux binary path, memoized per engine instance. - - ``shutil.which`` walks ``$PATH`` on every call (~50µs); the engine - invokes it on the hot path of every command, so caching the - result for the lifetime of the engine instance is a free win. - ``TmuxCommandNotFound`` is intentionally not memoized. - """ - if self._resolved_tmux_bin is None: - resolved = shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound - self._resolved_tmux_bin = resolved - return self._resolved_tmux_bin - def run_batch( self, requests: t.Sequence[CommandRequest], diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py index 8a6213331..cd35c93de 100644 --- a/src/libtmux/experimental/engines/subprocess.py +++ b/src/libtmux/experimental/engines/subprocess.py @@ -3,21 +3,19 @@ Executes tmux via the CLI binary, one fork per command, mirroring today's :class:`libtmux.common.tmux_cmd` output handling: ``backslashreplace`` decoding and trailing-blank stripping. A tmux-side failure is returned as data (nonzero -``returncode`` plus ``stderr``); only a missing binary raises. ``server_args`` -carries the -connection flags (``-L``/``-S``/``-f``/``-2``) so the engine can target a -specific tmux server. +``returncode`` plus ``stderr``); only a missing binary raises. The engine holds a +:class:`~.connection.ServerConnection`, which owns the tmux binary and the +connection flags (``-L``/``-S``/``-f``/``-2``) that target one tmux server. """ from __future__ import annotations -import shutil import subprocess import typing as t from libtmux import exc -from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.engines.connection import ServerConnection if t.TYPE_CHECKING: import pathlib @@ -32,7 +30,7 @@ class SubprocessEngine: Parameters ---------- tmux_bin : str or pathlib.Path or None - The tmux binary; resolved via :func:`shutil.which` when ``None``. + The tmux binary; resolved from ``$PATH`` when ``None``. server_args : Sequence[str] Connection flags inserted before the command (e.g. ``("-L", "test")`` or ``("-Lmysocket",)``). @@ -44,11 +42,22 @@ def __init__( *, server_args: Sequence[str] = (), ) -> None: - self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None - self.server_args = tuple(server_args) - self._resolved_bin: str | None = None - self._tmux_version: str | None = None - self._version_probed = False + self._conn = ServerConnection.of(tmux_bin, server_args) + + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before every tmux subcommand.""" + return self._conn.args def tmux_version(self) -> str | None: """Report this engine's tmux version (``tmux -V``), memoized. @@ -56,29 +65,11 @@ def tmux_version(self) -> str | None: Returns ``None`` when the binary is missing or its version cannot be parsed, so version resolution degrades to "assume latest". """ - if not self._version_probed: - self._version_probed = True - try: - self._tmux_version = str(get_version(self._resolve_bin())) - except exc.LibTmuxException: - self._tmux_version = None - return self._tmux_version - - def _resolve_bin(self) -> str: - """Return the tmux binary path, memoized for the engine instance.""" - if self.tmux_bin is not None: - return self.tmux_bin - if self._resolved_bin is None: - resolved = shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound - self._resolved_bin = resolved - return self._resolved_bin + return self._conn.tmux_version() def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command via subprocess and return its result.""" - tmux_bin = request.tmux_bin or self._resolve_bin() - cmd = [tmux_bin, *self.server_args, *request.args] + cmd = self._conn.argv(*request.args, tmux_bin=request.tmux_bin) try: process = subprocess.Popen( @@ -117,16 +108,5 @@ def for_server(cls, server: t.Any) -> SubprocessEngine: Mirrors :meth:`libtmux.Server.cmd`'s connection-flag construction so the engine talks to the same tmux server as the object API. """ - server_args: list[str] = [] - if getattr(server, "socket_name", None): - server_args.append(f"-L{server.socket_name}") - if getattr(server, "socket_path", None): - server_args.append(f"-S{server.socket_path}") - if getattr(server, "config_file", None): - server_args.append(f"-f{server.config_file}") - colors = getattr(server, "colors", None) - if colors == 256: - server_args.append("-2") - elif colors == 88: - server_args.append("-8") - return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) + conn = ServerConnection.from_server(server) + return cls(tmux_bin=conn.tmux_bin, server_args=conn.args) From 363cfe0ebd9721e6d35a9b21edaddce2f15840d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:40:12 -0500 Subject: [PATCH 155/223] Chain(fix[_chain]): Wire the fold guard, import the rule why: _chain re-inlined tmux's success rule (`returncode == 0 and not stderr`) at two attribution sites, so the definitive rule in ops/results.py could drift out from under them. Worse, ensure_chainable -- the fail-closed guard against folding an op that captures output or creates an object -- was dead code: no rendering path called it, so a capturing op folded into a `;` chain would have silently mis-attributed its stdout. what: - Import status_for from ops/results.py in attribute() and attribute_marked() instead of re-inlining the success rule - Run ensure_chainable over every op in render_chain() - Run ensure_chainable over render_marked()'s decorates (the create is the one non-chainable op the fold tolerates: its captured id is read back from stdout[0]) - Document the guard on the module and on both rendering paths, with doctests that show the fold failing closed --- src/libtmux/experimental/ops/_chain.py | 48 ++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 3bf94a389..d474d9b30 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -11,6 +11,12 @@ on success every member is ``complete``; on failure the first member is ``failed`` and the rest are ``skipped`` (the status the spine reserves for exactly this case). + +Because that merged stdout has no per-command boundary, folding an op that +*captures* output (or creates an object whose id it prints) would silently +mis-attribute those lines. :func:`ensure_chainable` is the fail-closed guard for +that, and every rendering path that emits a ``;`` fold runs it over the ops it is +about to fold -- so the mistake raises here instead of corrupting a result. """ from __future__ import annotations @@ -21,6 +27,7 @@ from libtmux.experimental.ops._types import PaneId, Special from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.results import status_for if t.TYPE_CHECKING: from collections.abc import Iterator, Sequence @@ -31,7 +38,18 @@ def ensure_chainable(op: Operation[t.Any]) -> None: - """Raise if *op* cannot be folded into a ``;`` chain (fail closed).""" + """Raise if *op* cannot be folded into a ``;`` chain (fail closed). + + Examples + -------- + >>> from libtmux.experimental.ops import CapturePane + >>> from libtmux.experimental.ops._types import PaneId + >>> ensure_chainable(CapturePane(target=PaneId("%1"))) + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.OperationError: operation 'capture_pane' is not + chainable; it produces output or creates an object and must dispatch on its own + """ if not op.chainable: msg = ( f"operation {op.kind!r} is not chainable; it produces output or " @@ -62,9 +80,24 @@ def render_chain( ... RenameWindow(target=WindowId("@1"), name="edit"), ... ]) ('send-keys', '-t', '%1', 'vim', 'Enter', ';', 'rename-window', '-t', '@1', 'edit') + + Every op is checked against :func:`ensure_chainable` first: a capturing or + creating op has nowhere to put its stdout in a merged chain result, so it + fails closed instead of folding. + + >>> from libtmux.experimental.ops import CapturePane, SendKeys + >>> render_chain([ + ... SendKeys(target=PaneId("%1"), keys="vim"), + ... CapturePane(target=PaneId("%1")), + ... ]) + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.OperationError: operation 'capture_pane' is not + chainable; it produces output or creates an object and must dispatch on its own """ out: list[str] = [] for index, op in enumerate(ops): + ensure_chainable(op) if index: out.append(";") out.extend(_escape_arg(token) for token in op.render(version=version)) @@ -77,7 +110,7 @@ def attribute( version: str | None = None, ) -> list[Result]: """Split one merged ``;``-chain result into a typed result per operation.""" - if merged.returncode == 0 and not merged.stderr: + if status_for(merged.returncode, merged.stderr) == "complete": return [op.result_with_status("complete", version=version) for op in ops] first, *rest = ops results: list[Result] = [ @@ -103,11 +136,20 @@ def render_marked( Emits `` ; select-pane -m ; ... ; select-pane -M``: the new pane is marked, every decorate addresses it through tmux's ``{marked}`` register, and the mark is cleared at the end. + + The *create* is the one non-chainable op the fold tolerates -- its captured id + is the whole point, and :func:`attribute_marked` reads it back from + ``stdout[0]``. That is exactly why every *decorate* must be chainable: a + capturing decorate would interleave its lines into the same merged stdout and + the captured id would be read from the wrong line. :func:`ensure_chainable` + fails that closed. """ parts: list[tuple[str, ...]] = [ create.render(version=version), ("select-pane", "-m"), ] + for op in decorates: + ensure_chainable(op) parts.extend( dataclasses.replace(op, target=Special("{marked}")).render(version=version) for op in decorates @@ -133,7 +175,7 @@ def attribute_marked( # target is unresolved and cannot render. marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] if new_id is None: - if merged.returncode == 0 and not merged.stderr: + if status_for(merged.returncode, merged.stderr) == "complete": # A non-capturing creator (capture=False) succeeded but emitted no # id; every command in the fold ran. create_result = create.build_result(returncode=0, version=version) From 2ca68dc11ab64d565fe8ea7ecf8ec08a532d239a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:42:50 -0500 Subject: [PATCH 156/223] Experimental(refactor[dedup]): Import instead of re-inline why: Six helpers had been re-implemented rather than imported, so a fix to one copy silently left the others behind: the docstring first-line summary (3 clones), the optional-target resolver, the workspace on_exists preflight, the caller's socket-path reconstruction, the plan-outcome projection, and the pane-readiness poll (2 clones, one of which had already drifted its poll budget). what: - Add ops/_docstring.py first_line(); catalog.py, mcp/registry.py and mcp/fastmcp_adapter.py import it (the adapter keeps its `| None` contract at the call site) - Add experimental/_wait_pane.py holding the cursor-probe poll; fluent.py and workspace/runner.py import wait_pane/await_pane - mcp/vocabulary/option.py imports _resolve.opt_target - workspace/sets.py imports runner.py's _preflight_sync/_preflight_async - mcp/vocabulary/_caller.py extracts socket_path_of() and same_socket_path() from the two copies in socket_matches / socket_could_match - mcp/plan_tools.py adds PlanOutcome.to_dict() and _to_outcome(); the adapter's plan tools return outcome.to_dict() --- src/libtmux/experimental/_wait_pane.py | 99 +++++++++++++++++++ src/libtmux/experimental/fluent.py | 31 +----- .../experimental/mcp/fastmcp_adapter.py | 39 ++------ src/libtmux/experimental/mcp/plan_tools.py | 43 ++++---- src/libtmux/experimental/mcp/registry.py | 11 +-- .../experimental/mcp/vocabulary/_caller.py | 69 ++++++++----- .../experimental/mcp/vocabulary/option.py | 11 +-- src/libtmux/experimental/ops/_docstring.py | 36 +++++++ src/libtmux/experimental/ops/catalog.py | 14 +-- src/libtmux/experimental/workspace/runner.py | 28 +----- src/libtmux/experimental/workspace/sets.py | 51 ++-------- 11 files changed, 230 insertions(+), 202 deletions(-) create mode 100644 src/libtmux/experimental/_wait_pane.py create mode 100644 src/libtmux/experimental/ops/_docstring.py diff --git a/src/libtmux/experimental/_wait_pane.py b/src/libtmux/experimental/_wait_pane.py new file mode 100644 index 000000000..8e6465002 --- /dev/null +++ b/src/libtmux/experimental/_wait_pane.py @@ -0,0 +1,99 @@ +"""Wait for a freshly created pane's shell to draw its prompt. + +tmux returns a pane id the instant it forks the pane, long before the shell in it +has printed a prompt -- so a ``send-keys`` fired immediately can be swallowed. The +fix both builders use is the same: poll ``#{cursor_x},#{cursor_y}`` until the +cursor leaves the origin, then proceed. + +This is a *host* step: it must run between tmux dispatches, never inside a fold, +so both drivers replay it from their plan's ``on_step`` hook (the workspace runner +through a compiled :class:`~.workspace.compiler.HostStep`, the fluent builder +through its recorded host action). Only the poll itself is shared here. +""" + +from __future__ import annotations + +import asyncio +import time +import typing as t + +from libtmux.experimental.ops import DisplayMessage, arun, run +from libtmux.experimental.ops.plan import _resolve + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + +#: Pane-readiness poll budget: ~2s at a 50ms cadence (matches tmuxp's timeout). +WAIT_PANE_POLLS = 40 +WAIT_PANE_INTERVAL = 0.05 +CURSOR_FMT = "#{cursor_x},#{cursor_y}" + + +def pane_ready(cursor: str) -> bool: + """Whether the pane's cursor has left the origin (its shell prompt drew). + + Parameters + ---------- + cursor : str + A ``#{cursor_x},#{cursor_y}`` reading, or ``""`` when unreadable. + + Returns + ------- + bool + + Examples + -------- + >>> pane_ready("2,1") + True + >>> pane_ready("0,0") + False + >>> pane_ready("") + False + """ + return bool(cursor) and cursor != "0,0" + + +def _cursor_probe( + pane: Target, + bindings: dict[int | tuple[int, str], str], +) -> Operation[t.Any]: + """Build the cursor read for *pane*, resolving a forward ref against bindings.""" + return _resolve(DisplayMessage(target=pane, message=CURSOR_FMT), bindings) + + +def wait_pane( + engine: TmuxEngine, + pane: Target, + bindings: dict[int | tuple[int, str], str], + version: str | None = None, +) -> bool: + """Poll *pane* until its prompt draws; return whether it did within the budget. + + Returns ``False`` on exhaustion rather than raising: a pane whose shell never + moves the cursor (a full-screen program, a bare ``cat``) is not an error, and + the build proceeds exactly as it did before the wait was requested. + """ + op = _cursor_probe(pane, bindings) + for _ in range(WAIT_PANE_POLLS): + if pane_ready(run(op, engine, version=version).text): + return True + time.sleep(WAIT_PANE_INTERVAL) + return False + + +async def await_pane( + engine: AsyncTmuxEngine, + pane: Target, + bindings: dict[int | tuple[int, str], str], + version: str | None = None, +) -> bool: + """Async sibling of :func:`wait_pane` (same budget, same exhaustion contract).""" + op = _cursor_probe(pane, bindings) + for _ in range(WAIT_PANE_POLLS): + result = await arun(op, engine, version=version) + if pane_ready(result.text): + return True + await asyncio.sleep(WAIT_PANE_INTERVAL) + return False diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index 24fc58d59..dbe17c624 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -36,6 +36,7 @@ import typing as t from dataclasses import dataclass, field +from libtmux.experimental._wait_pane import await_pane, wait_pane from libtmux.experimental.ops import ( BoundedPlanner, DisplayMessage, @@ -44,10 +45,7 @@ NameRef, NewSession, NewWindow, - arun, - run, ) -from libtmux.experimental.ops.plan import _resolve from libtmux.experimental.query import ForwardPaneRef, _PaneRefBase if t.TYPE_CHECKING: @@ -55,20 +53,12 @@ from libtmux.experimental.ops import Planner, PlanResult, StepReport from libtmux.experimental.ops._types import SlotRef, Target -_CURSOR_FMT = "#{cursor_x},#{cursor_y}" -_WAIT_PANE_POLLS = 40 -_WAIT_PANE_INTERVAL = 0.05 #: The probe format for a session find-or-create -- the ids #: ``NewSession(capture_panes=True)`` captures, so a found session binds the #: same self/window/pane slots a created one would. _SESSION_PROBE = "#{session_id} #{window_id} #{pane_id}" -def _pane_ready(cursor: str) -> bool: - """Whether a pane's cursor has left the origin (its shell prompt drew).""" - return bool(cursor) and cursor != "0,0" - - @dataclass(frozen=True) class _HostAction: """A host-side pause recorded after an operation (a hard fold boundary).""" @@ -258,14 +248,7 @@ def on_step(report: StepReport) -> None: if action.kind == "sleep": time.sleep(action.seconds) elif action.pane is not None: - op = _resolve( - DisplayMessage(target=action.pane, message=_CURSOR_FMT), - report.bindings, - ) - for _ in range(_WAIT_PANE_POLLS): - if _pane_ready(run(op, engine, version=version).text): - break - time.sleep(_WAIT_PANE_INTERVAL) + wait_pane(engine, action.pane, report.bindings, version) return self.plan.execute( engine, @@ -298,15 +281,7 @@ async def on_step(report: StepReport) -> None: if action.kind == "sleep": await asyncio.sleep(action.seconds) elif action.pane is not None: - op = _resolve( - DisplayMessage(target=action.pane, message=_CURSOR_FMT), - report.bindings, - ) - for _ in range(_WAIT_PANE_POLLS): - result = await arun(op, engine, version=version) - if _pane_ready(result.text): - break - await asyncio.sleep(_WAIT_PANE_INTERVAL) + await await_pane(engine, action.pane, report.bindings, version) return await self.plan.aexecute( engine, diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index e79a01457..1eac7b46c 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -29,6 +29,7 @@ from libtmux.experimental.mcp import vocabulary from libtmux.experimental.mcp.registry import OperationToolRegistry from libtmux.experimental.mcp.vocabulary._caller import CallerContext +from libtmux.experimental.ops._docstring import first_line if t.TYPE_CHECKING: from collections.abc import Callable @@ -195,14 +196,6 @@ def _instructions(ctx: CallerContext, *, events_enabled: bool = False) -> str: return "\n\n".join(segments) -def _summary(doc: str | None) -> str | None: - """Return the first non-empty docstring line.""" - for line in (doc or "").splitlines(): - if line.strip(): - return line.strip() - return None - - def _bind_engine( fn: Callable[..., t.Any], engine: TmuxEngine | AsyncTmuxEngine, @@ -258,7 +251,7 @@ def register_vocabulary( tool = FunctionTool.from_function( _bind_engine(fn, engine, is_async=is_async), name=name, - description=_summary(fn.__doc__), + description=first_line(fn.__doc__) or None, tags={safety}, annotations=annotations, meta={"anthropic/alwaysLoad": True} if name in _ANCHORS else None, @@ -287,7 +280,7 @@ def get_caller_context() -> CallerContext: tool = FunctionTool.from_function( get_caller_context, name="get_caller_context", - description=_summary(get_caller_context.__doc__), + description=first_line(get_caller_context.__doc__) or None, tags={"readonly"}, annotations=ToolAnnotations(title="get_caller_context", readOnlyHint=True), meta=( @@ -498,11 +491,7 @@ async def execute_plan( version=version, planner=_planner(planner), ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } + return outcome.to_dict() async def build_workspace( spec: dict[str, t.Any], @@ -516,11 +505,7 @@ async def build_workspace( version=version, preflight=preflight, ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } + return outcome.to_dict() tools.append((execute_plan, "mutating")) tools.append((build_workspace, "mutating")) @@ -538,11 +523,7 @@ def execute_plan( # type: ignore[misc] version=version, planner=_planner(planner), ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } + return outcome.to_dict() def build_workspace( # type: ignore[misc] spec: dict[str, t.Any], @@ -556,11 +537,7 @@ def build_workspace( # type: ignore[misc] version=version, preflight=preflight, ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } + return outcome.to_dict() tools.append((execute_plan, "mutating")) tools.append((build_workspace, "mutating")) @@ -574,7 +551,7 @@ def build_workspace( # type: ignore[misc] tool = FunctionTool.from_function( fn, name=fn.__name__, - description=_summary(fn.__doc__), + description=first_line(fn.__doc__) or None, tags={"plan", safety}, annotations=annotations, ) diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py index fb503af08..199b97e93 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -20,7 +20,7 @@ if t.TYPE_CHECKING: from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.mcp.registry import OperationToolRegistry - from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.plan import LazyPlan, PlanResult from libtmux.experimental.ops.planner import Planner @@ -80,6 +80,19 @@ class PlanOutcome: results: list[dict[str, t.Any]] bindings: dict[str, str] + def to_dict(self) -> dict[str, t.Any]: + """Render as the JSON object an MCP adapter returns to an agent.""" + return {"ok": self.ok, "results": self.results, "bindings": self.bindings} + + +def _to_outcome(result: PlanResult) -> PlanOutcome: + """Project a Core :class:`~..ops.plan.PlanResult` into a JSON-friendly outcome.""" + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) + def execute_plan( plan: LazyPlan, @@ -89,12 +102,7 @@ def execute_plan( planner: Planner | None = None, ) -> PlanOutcome: """Execute *plan* over *engine*; return JSON-friendly results + bindings.""" - result = plan.execute(engine, version=version, planner=planner) - return PlanOutcome( - ok=result.ok, - results=[result_to_dict(item) for item in result.results], - bindings=bindings_to_dict(result.bindings), - ) + return _to_outcome(plan.execute(engine, version=version, planner=planner)) async def aexecute_plan( @@ -105,12 +113,7 @@ async def aexecute_plan( planner: Planner | None = None, ) -> PlanOutcome: """Async sibling of :func:`execute_plan` (same shape).""" - result = await plan.aexecute(engine, version=version, planner=planner) - return PlanOutcome( - ok=result.ok, - results=[result_to_dict(item) for item in result.results], - bindings=bindings_to_dict(result.bindings), - ) + return _to_outcome(await plan.aexecute(engine, version=version, planner=planner)) @dataclass(frozen=True) @@ -163,11 +166,8 @@ def build_workspace( """ from libtmux.experimental.workspace import analyze - result = analyze(spec).build(engine, version=version, preflight=preflight) - return PlanOutcome( - ok=result.ok, - results=[result_to_dict(item) for item in result.results], - bindings=bindings_to_dict(result.bindings), + return _to_outcome( + analyze(spec).build(engine, version=version, preflight=preflight), ) @@ -185,9 +185,6 @@ async def abuild_workspace( """ from libtmux.experimental.workspace import analyze - result = await analyze(spec).abuild(engine, version=version, preflight=preflight) - return PlanOutcome( - ok=result.ok, - results=[result_to_dict(item) for item in result.results], - bindings=bindings_to_dict(result.bindings), + return _to_outcome( + await analyze(spec).abuild(engine, version=version, preflight=preflight), ) diff --git a/src/libtmux/experimental/mcp/registry.py b/src/libtmux/experimental/mcp/registry.py index 1e1616989..c82cce5e4 100644 --- a/src/libtmux/experimental/mcp/registry.py +++ b/src/libtmux/experimental/mcp/registry.py @@ -14,6 +14,7 @@ from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor from libtmux.experimental.mcp.schema import schema_for_type from libtmux.experimental.ops import registry as ops_registry +from libtmux.experimental.ops._docstring import first_line if t.TYPE_CHECKING: from libtmux.experimental.ops.registry import OpSpec @@ -80,14 +81,6 @@ def _docstring_params(doc: str | None) -> dict[str, str]: return out -def _summary(doc: str | None) -> str: - """Return the first non-empty docstring line.""" - for line in (doc or "").splitlines(): - if line.strip(): - return line.strip() - return "" - - class OperationToolRegistry: """Build (and cache) a :class:`~.descriptor.ToolDescriptor` per operation. @@ -127,7 +120,7 @@ def descriptors(self) -> list[ToolDescriptor]: def _build(self, spec: OpSpec) -> ToolDescriptor: """Project one ``OpSpec`` into a tool descriptor.""" - description = _summary(spec.operation_cls.__doc__) + description = first_line(spec.operation_cls.__doc__) if spec.min_version: # Surface the whole-command version gate so an agent on an older tmux # sees the requirement up front instead of a raw VersionUnsupported. diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py index 2fde3d234..fbac941e5 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_caller.py +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -250,6 +250,45 @@ def caller_server_args(caller: CallerContext, *, explicit: bool) -> tuple[str, . return ("-S", caller.socket_path) +def socket_path_of(socket: str) -> str: + """Return the filesystem path an engine *socket* selector denotes. + + A ``-S`` selector (anything containing ``/``) already *is* the path. A bare + ``-L`` name is the per-user socket tmux derives from ``$TMUX_TMPDIR`` (default + ``/tmp``), so a bare name cannot collide with an unrelated socket's basename. + + Examples + -------- + >>> socket_path_of("/tmp/explicit") + '/tmp/explicit' + >>> socket_path_of("work").endswith("/work") + True + """ + if "/" in socket: + return socket + tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" + return f"{tmpdir}/tmux-{os.getuid()}/{socket}" + + +def same_socket_path(left: str, right: str) -> bool: + """Whether two socket paths point at the same file (realpath-compared). + + Falls back to a literal comparison when the paths cannot be resolved (a + broken symlink or an unreadable directory must not mask the guard). + + Examples + -------- + >>> same_socket_path("/tmp/s", "/tmp/s") + True + >>> same_socket_path("/tmp/s", "/tmp/other") + False + """ + try: + return os.path.realpath(left) == os.path.realpath(right) + except OSError: + return left == right + + def socket_matches(socket: str | None, caller: CallerContext) -> bool: """Whether an engine *socket* selector denotes the caller's tmux server. @@ -280,17 +319,7 @@ def socket_matches(socket: str | None, caller: CallerContext) -> bool: ) if caller.socket_path is None: return False - if "/" in socket: - try: - return os.path.realpath(socket) == os.path.realpath(caller.socket_path) - except OSError: - return socket == caller.socket_path - tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" - expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" - try: - return os.path.realpath(expected) == os.path.realpath(caller.socket_path) - except OSError: - return expected == caller.socket_path + return same_socket_path(socket_path_of(socket), caller.socket_path) def socket_could_match(socket: str | None, caller: CallerContext) -> bool: @@ -322,19 +351,11 @@ def socket_could_match(socket: str | None, caller: CallerContext) -> bool: # Ambient default engine: the caller's server only when the MCP inherited # $TMUX (process-env), not when its pane was parent-walked or overridden. return caller.source == "process-env" - if "/" in socket: - try: - return os.path.realpath(socket) == os.path.realpath(caller.socket_path) - except OSError: - return socket == caller.socket_path - tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" - expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" - try: - if os.path.realpath(expected) == os.path.realpath(caller.socket_path): - return True - except OSError: - pass - return caller.socket_path.rsplit("/", 1)[-1] == socket + if same_socket_path(socket_path_of(socket), caller.socket_path): + return True + # Last chance for a bare -L name whose reconstructed path did not resolve + # (e.g. a $TMUX_TMPDIR divergence): a basename match blocks under doubt. + return "/" not in socket and caller.socket_path.rsplit("/", 1)[-1] == socket def is_strict_caller( diff --git a/src/libtmux/experimental/mcp/vocabulary/option.py b/src/libtmux/experimental/mcp/vocabulary/option.py index 52f4787a7..6bd5bb664 100644 --- a/src/libtmux/experimental/mcp/vocabulary/option.py +++ b/src/libtmux/experimental/mcp/vocabulary/option.py @@ -3,18 +3,13 @@ from __future__ import annotations from libtmux.experimental.engines.base import AsyncTmuxEngine -from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._resolve import opt_target from libtmux.experimental.mcp.vocabulary._results import OptionMap from libtmux.experimental.ops import SetOption, ShowOptions, arun from libtmux.experimental.ops._types import Target -def _opt(target: str | Target | None) -> Target | None: - """Resolve an optional target, preserving ``None``.""" - return None if target is None else resolve_target(target) - - async def ashow_options( engine: AsyncTmuxEngine, target: str | Target | None = None, @@ -27,7 +22,7 @@ async def ashow_options( """Show tmux options as ``name -> value`` pairs (``show-options``).""" result = await arun( ShowOptions( - target=_opt(target), + target=opt_target(target), global_=global_, server=server, window=window, @@ -55,7 +50,7 @@ async def aset_option( ( await arun( SetOption( - target=_opt(target), + target=opt_target(target), option=option, value=value, global_=global_, diff --git a/src/libtmux/experimental/ops/_docstring.py b/src/libtmux/experimental/ops/_docstring.py new file mode 100644 index 000000000..43cd3c6d6 --- /dev/null +++ b/src/libtmux/experimental/ops/_docstring.py @@ -0,0 +1,36 @@ +"""Docstring introspection shared by the projections of an operation. + +An operation's docstring is its user-facing text: the catalog renders it into the +operation reference, and the MCP registry / adapter render it into a tool +description. All three want the same one-line summary, so it is derived once, +here. +""" + +from __future__ import annotations + + +def first_line(doc: str | None) -> str: + r"""Return the first non-empty line of *doc* (``""`` when there is none). + + Parameters + ---------- + doc : str | None + A docstring (``__doc__`` is ``None`` under ``python -OO``). + + Returns + ------- + str + + Examples + -------- + >>> first_line("Split a window.\n\nLonger prose follows.\n") + 'Split a window.' + >>> first_line("\n Indented summary.\n") + 'Indented summary.' + >>> first_line(None) + '' + """ + for line in (doc or "").splitlines(): + if line.strip(): + return line.strip() + return "" diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 888a056c7..a611c9428 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -13,6 +13,7 @@ import typing as t from dataclasses import dataclass +from libtmux.experimental.ops._docstring import first_line from libtmux.experimental.ops.registry import registry as default_registry if t.TYPE_CHECKING: @@ -20,17 +21,6 @@ from libtmux.experimental.ops.registry import OperationRegistry -def _summary(doc: str | None) -> str: - """Return the first non-empty line of a docstring.""" - if not doc: - return "" - for line in doc.strip().splitlines(): - stripped = line.strip() - if stripped: - return stripped - return "" - - @dataclass(frozen=True) class CatalogEntry: """One operation's catalog record, derived from its registry spec.""" @@ -96,7 +86,7 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: min_version=spec.min_version, flag_version_gates=dict(spec.flag_version_map), effects=dataclasses.asdict(spec.effects), - summary=_summary(spec.operation_cls.__doc__), + summary=first_line(spec.operation_cls.__doc__), ) for spec in reg.select() ] diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index f756d9e81..1b7a3ee49 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -27,15 +27,15 @@ import time import typing as t +from libtmux.experimental._wait_pane import await_pane, wait_pane from libtmux.experimental.ops import ( - DisplayMessage, HasSession, KillSession, arun, run, ) from libtmux.experimental.ops._types import NameRef -from libtmux.experimental.ops.plan import PlanResult, StepReport, _resolve +from libtmux.experimental.ops.plan import PlanResult, StepReport from libtmux.experimental.ops.planner import BoundedPlanner, MarkedPlanner from libtmux.experimental.workspace.compiler import compile_full from libtmux.experimental.workspace.events import WorkspaceBuilt, events_for @@ -50,17 +50,6 @@ from libtmux.experimental.workspace.ir import Workspace -#: Pane-readiness poll budget: ~2s at a 50ms cadence (matches tmuxp's timeout). -_WAIT_PANE_POLLS = 40 -_WAIT_PANE_INTERVAL = 0.05 -_CURSOR_FMT = "#{cursor_x},#{cursor_y}" - - -def _pane_ready(cursor: str) -> bool: - """Whether the pane's cursor has left the origin (its shell prompt drew).""" - return bool(cursor) and cursor != "0,0" - - def _run_host_sync( step: HostStep, engine: TmuxEngine, @@ -73,11 +62,7 @@ def _run_host_sync( elif step.kind == "script" and step.command is not None: subprocess.run(step.command, shell=True, cwd=step.cwd, check=False) elif step.kind == "wait_pane" and step.pane is not None: - op = _resolve(DisplayMessage(target=step.pane, message=_CURSOR_FMT), bindings) - for _ in range(_WAIT_PANE_POLLS): - if _pane_ready(run(op, engine, version=version).text): - return - time.sleep(_WAIT_PANE_INTERVAL) + wait_pane(engine, step.pane, bindings, version) async def _run_host_async( @@ -93,12 +78,7 @@ async def _run_host_async( proc = await asyncio.create_subprocess_shell(step.command, cwd=step.cwd) await proc.wait() elif step.kind == "wait_pane" and step.pane is not None: - op = _resolve(DisplayMessage(target=step.pane, message=_CURSOR_FMT), bindings) - for _ in range(_WAIT_PANE_POLLS): - result = await arun(op, engine, version=version) - if _pane_ready(result.text): - return - await asyncio.sleep(_WAIT_PANE_INTERVAL) + await await_pane(engine, step.pane, bindings, version) def _preflight_sync(ws: Workspace, engine: TmuxEngine, version: str | None) -> bool: diff --git a/src/libtmux/experimental/workspace/sets.py b/src/libtmux/experimental/workspace/sets.py index 8ef9f18ad..a22891745 100644 --- a/src/libtmux/experimental/workspace/sets.py +++ b/src/libtmux/experimental/workspace/sets.py @@ -14,8 +14,8 @@ import typing as t from dataclasses import dataclass, field -from libtmux.experimental.ops import HasSession, KillSession, LazyPlan, arun, run -from libtmux.experimental.ops._types import NameRef, SlotRef, Target +from libtmux.experimental.ops import LazyPlan +from libtmux.experimental.ops._types import SlotRef, Target from libtmux.experimental.ops.plan import PlanResult, StepReport from libtmux.experimental.ops.planner import BoundedPlanner, MarkedPlanner from libtmux.experimental.workspace.compiler import Compiled, HostStep, compile_full @@ -25,7 +25,12 @@ Variant, expand, ) -from libtmux.experimental.workspace.runner import _run_host_async, _run_host_sync +from libtmux.experimental.workspace.runner import ( + _preflight_async, + _preflight_sync, + _run_host_async, + _run_host_sync, +) if t.TYPE_CHECKING: from collections.abc import Awaitable, Callable, Iterable, Mapping @@ -267,46 +272,6 @@ def compile_workspaces( ) -def _preflight_sync( - workspace: Workspace, - engine: TmuxEngine, - version: str | None, -) -> bool: - """Apply one workspace's ``on_exists`` policy before a batch build.""" - exists = run(HasSession(target=NameRef(workspace.name)), engine, version=version) - if not exists.exists: - return False - if workspace.on_exists == "replace": - run(KillSession(target=NameRef(workspace.name)), engine, version=version) - return False - if workspace.on_exists == "reuse": - return True - msg = f"session {workspace.name!r} already exists (on_exists='error')" - raise FileExistsError(msg) - - -async def _preflight_async( - workspace: Workspace, - engine: AsyncTmuxEngine, - version: str | None, -) -> bool: - """Async sibling of :func:`_preflight_sync`.""" - result = await arun( - HasSession(target=NameRef(workspace.name)), - engine, - version=version, - ) - if not result.exists: - return False - if workspace.on_exists == "replace": - await arun(KillSession(target=NameRef(workspace.name)), engine, version=version) - return False - if workspace.on_exists == "reuse": - return True - msg = f"session {workspace.name!r} already exists (on_exists='error')" - raise FileExistsError(msg) - - def _split_reused_sync( workspaces: tuple[Workspace, ...], engine: TmuxEngine, From e144e119a66ec4df2392771fb5866bccd5aa9eb5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:44:04 -0500 Subject: [PATCH 157/223] Experimental(refactor[dead-code]): Delete unread surface why: Six pieces of surface were written and never read. The imsg protocol's six msg_* accessors and pack_message() were declared in the ImsgProtocolCodec Protocol and implemented in v8 with no caller, so the Protocol overstated what a codec must supply. EngineSpec.extra was never consulted, and the engine registry's docstring advertised a create_engine(EngineSpec) overload that does not exist. The MCP descriptor carried effects/version_gates/version_gate that no projection reads, and middleware kept a tool-batch summarizer for a shape it no longer sees. what: - Drop msg_exit/msg_write_open/msg_write/msg_write_close/msg_read_open/ msg_exiting and pack_message from the ImsgProtocolCodec Protocol and from ProtocolV8Codec - Drop EngineSpec.extra; correct engines/registry.py's docstring to describe the EngineKind it actually accepts - Drop ToolDescriptor.effects/version_gates and ParamDescriptor.version_gate plus the registry lines populating them - Drop mcp/middleware.py's _summarize_tool_batch_operation_args and the _summarize_nested_operation_args indirection over it - Register the imsg engine under EngineKind.IMSG.value, not a bare string --- src/libtmux/experimental/engines/base.py | 3 +- src/libtmux/experimental/engines/imsg/base.py | 35 +++------------- src/libtmux/experimental/engines/imsg/v8.py | 42 ------------------- src/libtmux/experimental/engines/registry.py | 7 ++-- src/libtmux/experimental/mcp/descriptor.py | 5 +-- src/libtmux/experimental/mcp/middleware.py | 22 +--------- src/libtmux/experimental/mcp/registry.py | 7 +--- 7 files changed, 15 insertions(+), 106 deletions(-) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index b813a12ba..ac84dd20c 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -12,7 +12,7 @@ import enum import shlex import typing as t -from dataclasses import dataclass, field +from dataclasses import dataclass if t.TYPE_CHECKING: import pathlib @@ -123,7 +123,6 @@ class EngineSpec: kind: EngineKind protocol_version: int | None = None - extra: t.Mapping[str, t.Any] = field(default_factory=dict) def __post_init__(self) -> None: """Normalize and validate the spec.""" diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 30265cd6a..12ddce141 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -13,7 +13,11 @@ import typing as t from libtmux import exc -from libtmux.experimental.engines.base import CommandRequest, CommandResult +from libtmux.experimental.engines.base import ( + CommandRequest, + CommandResult, + EngineKind, +) from libtmux.experimental.engines.connection import ServerConnection from libtmux.experimental.engines.imsg.exc import ( ImsgProtocolError, @@ -47,9 +51,6 @@ class ImsgProtocolCodec(t.Protocol): def pack_frame(self, frame: ImsgFrame) -> bytes: """Return wire bytes for a typed imsg frame.""" - def pack_message(self, msg_type: int, payload: bytes, *, peer_id: int) -> bytes: - """Return a framed tmux imsg message without an attached FD.""" - def unpack_header(self, data: bytes) -> ImsgHeader: """Decode a tmux imsg header.""" @@ -125,10 +126,6 @@ def msg_version(self) -> int: def msg_ready(self) -> int: """Return the numeric ``MSG_READY`` message type.""" - @property - def msg_exit(self) -> int: - """Return the numeric ``MSG_EXIT`` message type.""" - @property def msg_exited(self) -> int: """Return the numeric ``MSG_EXITED`` message type.""" @@ -141,26 +138,6 @@ def msg_shutdown(self) -> int: def msg_flags(self) -> int: """Return the numeric ``MSG_FLAGS`` message type.""" - @property - def msg_write_open(self) -> int: - """Return the numeric ``MSG_WRITE_OPEN`` message type.""" - - @property - def msg_write(self) -> int: - """Return the numeric ``MSG_WRITE`` message type.""" - - @property - def msg_write_close(self) -> int: - """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" - - @property - def msg_read_open(self) -> int: - """Return the numeric ``MSG_READ_OPEN`` message type.""" - - @property - def msg_exiting(self) -> int: - """Return the numeric ``MSG_EXITING`` message type.""" - def exiting_message(self, *, peer_id: int) -> ImsgFrame: """Build a ``MSG_EXITING`` notification.""" @@ -899,4 +876,4 @@ def _server_available(socket_path: str) -> bool: return True -register_engine("imsg", ImsgEngine) +register_engine(EngineKind.IMSG.value, ImsgEngine) diff --git a/src/libtmux/experimental/engines/imsg/v8.py b/src/libtmux/experimental/engines/imsg/v8.py index c1687f3f5..b091ac536 100644 --- a/src/libtmux/experimental/engines/imsg/v8.py +++ b/src/libtmux/experimental/engines/imsg/v8.py @@ -164,11 +164,6 @@ def msg_ready(self) -> int: """Return the numeric ``MSG_READY`` message type.""" return int(MessageType.MSG_READY) - @property - def msg_exit(self) -> int: - """Return the numeric ``MSG_EXIT`` message type.""" - return int(MessageType.MSG_EXIT) - @property def msg_exited(self) -> int: """Return the numeric ``MSG_EXITED`` message type.""" @@ -184,31 +179,6 @@ def msg_flags(self) -> int: """Return the numeric ``MSG_FLAGS`` message type.""" return int(MessageType.MSG_FLAGS) - @property - def msg_write_open(self) -> int: - """Return the numeric ``MSG_WRITE_OPEN`` message type.""" - return int(MessageType.MSG_WRITE_OPEN) - - @property - def msg_write(self) -> int: - """Return the numeric ``MSG_WRITE`` message type.""" - return int(MessageType.MSG_WRITE) - - @property - def msg_write_close(self) -> int: - """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" - return int(MessageType.MSG_WRITE_CLOSE) - - @property - def msg_read_open(self) -> int: - """Return the numeric ``MSG_READ_OPEN`` message type.""" - return int(MessageType.MSG_READ_OPEN) - - @property - def msg_exiting(self) -> int: - """Return the numeric ``MSG_EXITING`` message type.""" - return int(MessageType.MSG_EXITING) - def frame_message( self, msg_type: int | MessageType, @@ -258,18 +228,6 @@ def pack_frame(self, frame: ImsgFrame) -> bytes: ) return header + frame.payload - def pack_message( - self, - msg_type: int, - payload: bytes, - *, - peer_id: int, - ) -> bytes: - """Return a framed tmux imsg message without an attached FD.""" - return self.pack_frame( - self.frame_message(msg_type, payload, peer_id=peer_id), - ) - def unpack_header(self, data: bytes) -> ImsgHeader: """Decode and validate a tmux imsg header.""" if len(data) != IMSG_HEADER_SIZE: diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py index b3786f211..915691603 100644 --- a/src/libtmux/experimental/engines/registry.py +++ b/src/libtmux/experimental/engines/registry.py @@ -1,8 +1,9 @@ """A name-keyed registry of engine factories. -Lets engines be created by name (or :class:`~.base.EngineSpec`) so downstream -code and the contract suite can select a transport without importing its class. -Fails closed on an unknown name. +Lets engines be created by name -- or by the :class:`~.base.EngineKind` an +:class:`~.base.EngineSpec` carries -- so downstream code and the contract suite +can select a transport without importing its class. Fails closed on an unknown +name. """ from __future__ import annotations diff --git a/src/libtmux/experimental/mcp/descriptor.py b/src/libtmux/experimental/mcp/descriptor.py index c03cc016c..fdac8bb8e 100644 --- a/src/libtmux/experimental/mcp/descriptor.py +++ b/src/libtmux/experimental/mcp/descriptor.py @@ -38,7 +38,6 @@ class ParamDescriptor: is_required: bool = True item_origin: str | None = None description: str | None = None - version_gate: str | None = None def to_json_schema(self) -> dict[str, t.Any]: """Render this parameter as a JSON-schema fragment. @@ -74,7 +73,7 @@ class ToolDescriptor: result_type, result_schema The result class name and a JSON schema for its payload. annotations, tags - MCP-style hints derived from safety/effects. + MCP-style hints derived from the safety tier. operation_cls The operation class :meth:`build` instantiates. min_version @@ -92,8 +91,6 @@ class ToolDescriptor: result_schema: Mapping[str, t.Any] annotations: Mapping[str, bool] tags: frozenset[str] - version_gates: Mapping[str, str] - effects: Mapping[str, t.Any] operation_cls: type[Operation[t.Any]] min_version: str | None = None diff --git a/src/libtmux/experimental/mcp/middleware.py b/src/libtmux/experimental/mcp/middleware.py index 4b4f5c2fb..8d63fce92 100644 --- a/src/libtmux/experimental/mcp/middleware.py +++ b/src/libtmux/experimental/mcp/middleware.py @@ -485,26 +485,6 @@ def _summarize_send_keys_operation_args(args: dict[str, t.Any]) -> dict[str, t.A return summary -def _summarize_tool_batch_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: - """Summarize one generic tool-batch operation for audit logging.""" - summary: dict[str, t.Any] = {} - for key, value in args.items(): - if key == "tool" and isinstance(value, str): - summary[key] = value - elif key == "arguments" and isinstance(value, dict): - summary[key] = _summarize_args(value) - else: - summary[key] = _redacted_value_shape(value) - return summary - - -def _summarize_nested_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: - """Summarize a known nested operation shape.""" - if "tool" in args or "arguments" in args: - return _summarize_tool_batch_operation_args(args) - return _summarize_send_keys_operation_args(args) - - def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: """Summarize tool arguments for audit logging. @@ -540,7 +520,7 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: elif key in _NESTED_ARG_LIST_NAMES: if isinstance(value, list): summary[key] = [ - _summarize_nested_operation_args(item) + _summarize_send_keys_operation_args(item) if isinstance(item, dict) else _redacted_value_shape(item) for item in value diff --git a/src/libtmux/experimental/mcp/registry.py b/src/libtmux/experimental/mcp/registry.py index c82cce5e4..400d03ed1 100644 --- a/src/libtmux/experimental/mcp/registry.py +++ b/src/libtmux/experimental/mcp/registry.py @@ -2,8 +2,8 @@ One descriptor per registered operation ``kind``, derived by introspecting the operation dataclass (fields + type hints + NumPy-docstring params) and its -``OpSpec`` metadata (scope/safety/effects/version gates). Zero MCP-framework -coupling: the result is plain data + a builder. +``OpSpec`` metadata (scope, safety, the whole-command version gate). Zero +MCP-framework coupling: the result is plain data + a builder. """ from __future__ import annotations @@ -136,8 +136,6 @@ def _build(self, spec: OpSpec) -> ToolDescriptor: result_schema=schema_for_type(spec.result_cls), annotations=_ANNOTATIONS.get(spec.safety, {}), tags=frozenset({spec.safety}), - version_gates=dict(spec.flag_version_map), - effects=dataclasses.asdict(spec.effects), operation_cls=spec.operation_cls, min_version=spec.min_version, ) @@ -160,6 +158,5 @@ def _params(self, spec: OpSpec) -> dict[str, ParamDescriptor]: and field.default_factory is dataclasses.MISSING ), description=docs.get(field.name), - version_gate=spec.flag_version_map.get(field.name), ) return params From 29ef81e011a6e2716aa526c99d669d2c0f99370d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:40:10 -0500 Subject: [PATCH 158/223] Engines(feat[control-mode]): Add unescape_control_output why: Control mode does not forward pane output verbatim: tmux writes every non-printable byte -- and the backslash itself -- as a backslash plus three octal digits. Any reader that scans %output for raw bytes must undo that first or it can never match. That decoding is transport knowledge, so it belongs next to render_control_line in the engines layer rather than in whichever consumer notices the escaping first. what: - Add unescape_control_output() to engines/base.py, the inbound counterpart to render_control_line: octal escapes decode back to the bytes the pane wrote, and bytes tmux left alone pass through, so an already-raw payload is unaffected. - Cover it with doctests over printable text, an OSC escape sequence, and multi-byte UTF-8. --- src/libtmux/experimental/engines/base.py | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index ac84dd20c..a04cf08b2 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -10,6 +10,7 @@ from __future__ import annotations import enum +import re import shlex import typing as t from dataclasses import dataclass @@ -18,6 +19,9 @@ import pathlib from collections.abc import Sequence +#: tmux escapes a byte in ``%output`` as a backslash plus three octal digits. +_CONTROL_OCTAL = re.compile(rb"\\([0-7]{3})") + def render_control_line(argv: Sequence[str]) -> str: """Render a tmux argv as a control-mode (``tmux -C``) command line. @@ -36,6 +40,39 @@ def render_control_line(argv: Sequence[str]) -> str: return " ".join(token if token == ";" else shlex.quote(token) for token in argv) +def unescape_control_output(payload: str) -> bytes: + r"""Decode a control-mode ``%output`` payload back to the bytes the pane wrote. + + tmux does not forward pane output verbatim: in a ``%output`` notification it + writes every non-printable byte -- and the backslash itself -- as a backslash + followed by three octal digits. A reader that scans for raw bytes must undo + this first, or it can never match: an ``ESC`` (``0x1b``) arrives on the wire + as the four *characters* ``\``, ``0``, ``3``, ``3``. + + Bytes tmux left alone pass through untouched, so feeding this an already-raw + payload is harmless. + + Examples + -------- + Printable output is returned as-is: + + >>> unescape_control_output("hello world") + b'hello world' + + An escape sequence tmux octal-escaped comes back as real bytes: + + >>> unescape_control_output(r"\033]3008;state=idle\033\134") + b'\x1b]3008;state=idle\x1b\\' + + Multi-byte UTF-8 survives the round trip: + + >>> unescape_control_output(r"caf\303\251").decode() + 'café' + """ + raw = payload.encode("utf-8", "surrogateescape") + return _CONTROL_OCTAL.sub(lambda m: bytes((int(m.group(1), 8),)), raw) + + @dataclass(frozen=True) class CommandRequest: """A rendered tmux command, ready for an engine to execute. From f78c797a70e86e642644b05ff2bf6385eff177ae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Jul 2026 06:27:18 -0500 Subject: [PATCH 159/223] Skill(feat[bench]): Add engine-build benchmark skill why: scripts/bench_engines.py has three subcommands, six engines, and several easy-to-misread gotchas (wait-mode apples-to-oranges, hyperfine understating the builder). Give agents a tailored reference so they run and read it correctly. what: - Add .agents/skills/benchmarking-engine-builds/SKILL.md - Cover run/cell/profile, the six engines, and how to read percentiles - Flag the uv-run requirement and the wait-mode comparison trap --- .../benchmarking-engine-builds/SKILL.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .agents/skills/benchmarking-engine-builds/SKILL.md diff --git a/.agents/skills/benchmarking-engine-builds/SKILL.md b/.agents/skills/benchmarking-engine-builds/SKILL.md new file mode 100644 index 000000000..76ab4a50c --- /dev/null +++ b/.agents/skills/benchmarking-engine-builds/SKILL.md @@ -0,0 +1,76 @@ +--- +name: benchmarking-engine-builds +description: Use when measuring or profiling how fast libtmux.experimental engines build tmux workspaces — comparing classic vs subprocess/control_mode/imsg/mock/pipelined, chasing a build-latency regression, reading percentile grids, or finding where a control-mode build spends its time (cProfile). Runs scripts/bench_engines.py hermetically on throwaway sockets. +--- + +# Benchmarking engine builds + +## Overview + +`scripts/bench_engines.py` times how long each experimental engine takes to +build a tmux session structure (`W` windows × `P` panes-per-window), sweeping +shapes × engines × wait-modes and reporting min/avg/median/p90/p95/p99/max. + +**Hermetic and safe to run beside a live tmux session:** every server gets its +own socket under a throwaway `mkdtemp` dir, `TMUX` is unset before libtmux is +imported, and an `atexit` hook kills every spawned server. The default tmux +server is never contacted. + +It is a PEP 723 script — **always launch it with `uv run`**, never `python`, or +its inline deps (`rich`, `typer`, editable `libtmux`) won't resolve. + +## When to use + +- Comparing engine build cost (which engine is fastest for a given shape). +- Checking whether a change to the ops/plan/engine layer moved build latency. +- Reading percentile spread (is p99 blowing out?) rather than a single number. +- Locating the hot path inside one engine's build (`profile` → cProfile cumtime). + +## Quick reference + +Run from the repo root. + +| Command | What it does | +|---|---| +| `uv run scripts/bench_engines.py run` | full grid (the clean signal) | +| `uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4` | cProfile one engine, print slowest by cumtime | +| `uv run scripts/bench_engines.py cell control_mode 8x4` | one isolated build (for wrapping in hyperfine) | + +`run` flags: `--shapes 1x1,1x4,3x3,5x4,8x4`, `--engines classic,subprocess,control_mode,imsg,mock,pipelined`, +`--wait` (ALSO measure with shell-readiness wait), `--runs 20`, `--warmup 3`, +`--json-out grid.json`. Shape is `windows x panes-per-window`. + +Engines: `classic` (Server/Session/Window/Pane API) · `subprocess` (one fork +per op) · `control_mode` (one persistent `tmux -C`) · `imsg` (AF_UNIX one-shot) · +`mock` (offline, in-memory Python floor) · `pipelined` (prototype: batch +independent creates via `run_batch`). + +## Reading the results + +- **`control_mode` is the fastest shipped engine** (~21× classic at 32 panes) + because it avoids a per-op `tmux` fork. `pipelined` edges it (~1.4×) by + batching independent creates into ~3 round-trips. +- **Builds are tmux-server-bound, not round-trip-bound** — one shell fork per + pane dominates, so cutting round-trips helps less than the count implies. + `mock` (~1–2 ms) is the pure-Python floor: the plan/compile layer is + negligible; the time is tmux. +- **`profile` shows ~68% in `select.epoll.poll`** inside `_read_blocks`: each + created id is read back before the next op targets it. Latency-bound. + +## Common mistakes + +- Running with `python` instead of `uv run` — PEP 723 deps don't resolve. +- **Comparing `--wait` against no-wait across engines.** Shell startup + (~0.8–2.1 s) dwarfs a fast build, so the ~20× engine win collapses to ~1.5× + once both sides wait. A classic-that-waits vs a builder-that-doesn't is + apples-to-oranges (this is the bogus "~79×" trap). Compare like with like. +- Trusting hyperfine whole-process wall time over the in-process grid — Python + startup + import dwarfs a 3 ms build and understates the builder. The + in-process `run` grid is the clean signal. +- Expecting `mock` under `--wait`: it has no real panes and is skipped. + +## Results & reproduction + +Committed results live in `scripts/bench-results/`: `RESULTS.md` (narrative + +tables), `grid.json` (no-wait grid), `wait.json` (wait comparison). Regenerate +the raw JSON with `--json-out`. From 7fe107cee64321bf9860f9805d305a44d037e191 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Jul 2026 06:27:38 -0500 Subject: [PATCH 160/223] Skill(chore[claude]): Symlink .claude/skills to .agents/skills why: Claude Code discovers skills under .claude/skills only, while .agents/skills is the agent-agnostic source of truth. One symlink gives both tools the same skills without duplicating content. .claude/ is globally gitignored, so this is force-added to track it per-repo. what: - Add .claude/skills -> ../.agents/skills symlink (force-added) --- .claude/skills | 1 + 1 file changed, 1 insertion(+) create mode 120000 .claude/skills diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file From 49775076844d59f6ec248a8ff6a5f880c0b58a3f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Jul 2026 06:46:13 -0500 Subject: [PATCH 161/223] Mcp(fix[mcp_swap]): Point agy at the config it actually reads why: The swap wrote agy's server config to ~/.gemini/antigravity/mcp_config.json, but the Antigravity CLI reads ~/.gemini/config/mcp_config.json, so swapped servers never loaded. Ports the fix already landed in libtmux-mcp/agentgrep/rampa. what: - CLIS["agy"].config_path -> ~/.gemini/config/mcp_config.json - Drop the stale "may read a different profile" docstring hedge - Note Grok/agy in the scope docstring (already supported) --- scripts/mcp_swap.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index db487ddad..058adb519 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -3,7 +3,7 @@ # requires-python = ">=3.10" # dependencies = ["tomlkit>=0.13"] # /// -"""Swap MCP server configs across Claude / Codex / Cursor / Gemini / Grok / Antigravity. +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini / Grok / agy. Use when you want every installed agent CLI to run a local checkout of an MCP server (editable) instead of a pinned release. ``use-local`` rewrites @@ -38,10 +38,9 @@ ``~/.claude.json``, ``~/.codex/config.toml``, ``~/.gemini/settings.json``, ``~/.grok/config.toml`` (TOML ``mcp_servers``, same shape as Codex), and - ``~/.gemini/antigravity/mcp_config.json`` (Antigravity, JSON - ``mcpServers``). The Antigravity desktop IDE and the ``agy`` CLI may - read different profiles; only the documented profile path above is - written. Workspace / project-local configs + ``~/.gemini/config/mcp_config.json`` (agy / Antigravity CLI, JSON + ``mcpServers`` — the shared-config file the CLI reads, sibling to the + ``config.json`` it loads at startup). Workspace / project-local configs (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, per-project ``projects..mcpServers`` entries inside ``~/.claude.json`` *are* recognised for Claude only) are NOT @@ -56,7 +55,7 @@ pre-flag behaviour. ``--scope user`` writes Claude's top-level ``mcpServers`` fallback so every project that has no per-project override picks up the swap; useful when QA-ing a branch across - many directories. Codex, Cursor, and Gemini have no per-project + many directories. Codex, Cursor, Gemini, Grok, and agy have no per-project layer in their config files; the flag is silently coerced to ``user`` for them. Both Claude scopes can coexist with independent backups; full ``revert`` unwinds in LIFO order. @@ -94,7 +93,7 @@ #: Claude config scope: ``"user"`` targets the user/system-level top-level #: ``mcpServers`` fallback that applies to every project without its own #: override; ``"project"`` targets the project-level per-project -#: ``projects..mcpServers`` node. Codex / Cursor / Gemini have no +#: ``projects..mcpServers`` node. Non-Claude CLIs have no #: per-project scope in their config files, so for those CLIs the scope #: is always normalised to ``"user"`` regardless of what was passed. Scope = t.Literal["user", "project"] @@ -231,15 +230,15 @@ class CLIInfo: fmt="toml", ), # Antigravity (the ``agy`` CLI). Its MCP config is the standard JSON - # ``mcpServers`` shape (same as Cursor / Gemini) under the - # Antigravity profile dir. The file may not exist until the IDE/CLI - # writes it and starts empty; ``load_config`` tolerates a 0-byte - # JSON file as ``{}``. Note: the desktop IDE and the ``agy`` CLI may - # read different profiles; this targets the documented profile path. + # ``mcpServers`` shape (same as Cursor / Gemini). The CLI reads + # ``~/.gemini/config/mcp_config.json`` — its shared-config dir, + # sibling to the ``config.json`` it loads at startup. The file may + # start empty until a server is added; ``load_config`` tolerates a + # 0-byte JSON file as ``{}``. "agy": CLIInfo( name="agy", binary="agy", - config_path=pathlib.Path.home() / ".gemini" / "antigravity" / "mcp_config.json", + config_path=(pathlib.Path.home() / ".gemini" / "config" / "mcp_config.json"), fmt="json", ), } @@ -317,9 +316,8 @@ class SwapEntry: def load_config(info: CLIInfo) -> t.Any: """Parse a CLI's config file (JSON or TOML) into an editable structure. - An empty JSON file is treated as an empty object ``{}`` rather than a - parse error: Antigravity's ``mcp_config.json`` is created empty until - a server is added, so a swap must be able to seed the first entry. + Empty JSON files are treated as empty objects so first-run MCP configs can + be seeded with their initial server entry. """ raw = info.config_path.read_bytes() if info.fmt == "json": From d5e8eb5cbd79882e0b4c7ece34a6f169d179ecc4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:28:07 -0500 Subject: [PATCH 162/223] Agents(feat[state]): Add AgentState enum + Agent record why: The shared vocabulary every agents module reads/writes. what: - AgentState (running/awaiting_input/idle/exited/unknown) with from_signal - frozen Agent record + is_running/is_awaiting helpers --- src/libtmux/experimental/agents/__init__.py | 3 + src/libtmux/experimental/agents/state.py | 92 +++++++++++++++++++++ tests/experimental/agents/__init__.py | 1 + tests/experimental/agents/test_state.py | 29 +++++++ 4 files changed, 125 insertions(+) create mode 100644 src/libtmux/experimental/agents/__init__.py create mode 100644 src/libtmux/experimental/agents/state.py create mode 100644 tests/experimental/agents/__init__.py create mode 100644 tests/experimental/agents/test_state.py diff --git a/src/libtmux/experimental/agents/__init__.py b/src/libtmux/experimental/agents/__init__.py new file mode 100644 index 000000000..cce9cebdd --- /dev/null +++ b/src/libtmux/experimental/agents/__init__.py @@ -0,0 +1,3 @@ +"""Agent-state monitoring over tmux (experimental).""" + +from __future__ import annotations diff --git a/src/libtmux/experimental/agents/state.py b/src/libtmux/experimental/agents/state.py new file mode 100644 index 000000000..757dcf413 --- /dev/null +++ b/src/libtmux/experimental/agents/state.py @@ -0,0 +1,92 @@ +"""The agent-state vocabulary: the AgentState enum and the Agent record.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass + + +class AgentState(str, enum.Enum): + """What a coding agent in a pane is doing. + + Examples + -------- + >>> AgentState.from_signal("running") + + >>> AgentState.from_signal("nonsense") + + """ + + RUNNING = "running" + AWAITING_INPUT = "awaiting_input" + IDLE = "idle" + EXITED = "exited" + UNKNOWN = "unknown" + + @classmethod + def from_signal(cls, value: str) -> AgentState: + """Map a hook's raw state string to an :class:`AgentState`. + + Unrecognized values become :attr:`UNKNOWN` rather than raising, so a + malformed signal can never crash the monitor. + + Examples + -------- + >>> AgentState.from_signal("AWAITING_INPUT") + + >>> AgentState.from_signal("garbage") + + """ + try: + return cls(value.strip().lower()) + except ValueError: + return cls.UNKNOWN + + +@dataclass(frozen=True) +class Agent: + """A pane's coding agent and its current state. + + Examples + -------- + >>> a = Agent(pane_id="%1", key="%1", name="claude", + ... state=AgentState.RUNNING, since=1.0, source="option", + ... pid=42, alive=True) + >>> a.is_running, a.is_awaiting + (True, False) + """ + + pane_id: str + key: str + name: str | None + state: AgentState + since: float + source: str + pid: int | None + alive: bool + + @property + def is_awaiting(self) -> bool: + """True when the agent is paused waiting on the human/orchestrator. + + Examples + -------- + >>> Agent(pane_id="%1", key="%1", name="claude", + ... state=AgentState.AWAITING_INPUT, since=1.0, source="option", + ... pid=42, alive=True).is_awaiting + True + """ + return self.state is AgentState.AWAITING_INPUT + + @property + def is_running(self) -> bool: + """True when the agent is actively working. + + Examples + -------- + >>> Agent(pane_id="%1", key="%1", name="claude", + ... state=AgentState.RUNNING, since=1.0, source="option", + ... pid=42, alive=True).is_running + True + """ + return self.state is AgentState.RUNNING diff --git a/tests/experimental/agents/__init__.py b/tests/experimental/agents/__init__.py new file mode 100644 index 000000000..c57b614f8 --- /dev/null +++ b/tests/experimental/agents/__init__.py @@ -0,0 +1 @@ +"""Tests for the agents module.""" diff --git a/tests/experimental/agents/test_state.py b/tests/experimental/agents/test_state.py new file mode 100644 index 000000000..367de9b25 --- /dev/null +++ b/tests/experimental/agents/test_state.py @@ -0,0 +1,29 @@ +"""Tests for the AgentState enum and Agent record.""" + +from __future__ import annotations + +from libtmux.experimental.agents.state import Agent, AgentState + + +def test_from_signal_maps_known_and_unknown() -> None: + """Test AgentState.from_signal maps known and unknown states.""" + assert AgentState.from_signal("running") is AgentState.RUNNING + assert AgentState.from_signal("awaiting_input") is AgentState.AWAITING_INPUT + assert AgentState.from_signal("idle") is AgentState.IDLE + assert AgentState.from_signal("garbage") is AgentState.UNKNOWN + + +def test_agent_helpers() -> None: + """Test Agent properties is_awaiting and is_running.""" + agent = Agent( + pane_id="%1", + key="%1", + name="claude", + state=AgentState.AWAITING_INPUT, + since=1.0, + source="option", + pid=42, + alive=True, + ) + assert agent.is_awaiting is True + assert agent.is_running is False From cf2ab07af0648489f86d5faa2716388dc256c16a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:30:41 -0500 Subject: [PATCH 163/223] Agents(feat[merge]): Add latest-wins Stamp ordering why: Out-of-order/replayed agent-state updates must converge to newest. what: - Stamp(counter, writer) with deterministic tie-break - latest() guard + pluggable Clock + MonotonicCounter default --- src/libtmux/experimental/agents/merge.py | 65 ++++++++++++++++++++++++ tests/experimental/agents/test_merge.py | 29 +++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/libtmux/experimental/agents/merge.py create mode 100644 tests/experimental/agents/test_merge.py diff --git a/src/libtmux/experimental/agents/merge.py b/src/libtmux/experimental/agents/merge.py new file mode 100644 index 000000000..23564aed2 --- /dev/null +++ b/src/libtmux/experimental/agents/merge.py @@ -0,0 +1,65 @@ +"""Latest-wins ordering: when two updates for one pane race, keep the newer. + +A ``Stamp`` is a logical clock ``(counter, writer)``. ``latest`` decides whether +an incoming stamp should replace the current one. The clock is pluggable: a +monotonic counter is single-host-correct; an HLC can drop in later for multi-host +without touching call sites. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +Clock = t.Callable[[], int] + + +@dataclass(frozen=True, order=True) +class Stamp: + """A logical-clock tag on one state update. + + Ordered by ``counter`` first, then ``writer`` (a deterministic tie-break when + two sources stamp the same counter). + + Examples + -------- + >>> Stamp(2, "option") > Stamp(1, "osc") + True + >>> Stamp(1, "osc") > Stamp(1, "option") + True + """ + + counter: int + writer: str + + +def latest(current: Stamp | None, incoming: Stamp) -> bool: + """Return ``True`` if *incoming* should replace *current* (it is newer). + + Examples + -------- + >>> latest(None, Stamp(0, "option")) + True + >>> latest(Stamp(5, "option"), Stamp(4, "option")) + False + """ + return current is None or incoming > current + + +class MonotonicCounter: + """A strictly-increasing integer clock for single-host stamping. + + Examples + -------- + >>> clock = MonotonicCounter() + >>> clock(), clock() + (1, 2) + """ + + def __init__(self) -> None: + self._value = 0 + + def __call__(self) -> int: + """Return the next integer (strictly greater than the previous).""" + self._value += 1 + return self._value diff --git a/tests/experimental/agents/test_merge.py b/tests/experimental/agents/test_merge.py new file mode 100644 index 000000000..b66bfa827 --- /dev/null +++ b/tests/experimental/agents/test_merge.py @@ -0,0 +1,29 @@ +"""Tests for latest-wins ordering.""" + +from __future__ import annotations + +from libtmux.experimental.agents.merge import MonotonicCounter, Stamp, latest + + +def test_latest_prefers_higher_counter() -> None: + """Test that latest() prefers higher counter values.""" + assert latest(Stamp(1, "option"), Stamp(2, "option")) is True + assert latest(Stamp(2, "option"), Stamp(1, "option")) is False + + +def test_latest_tie_breaks_on_writer() -> None: + """Test that latest() breaks ties using writer name in deterministic order.""" + # equal counters: deterministic tie-break, never a coin flip + assert latest(Stamp(1, "option"), Stamp(1, "osc")) is True + assert latest(Stamp(1, "osc"), Stamp(1, "option")) is False + + +def test_latest_accepts_first_value() -> None: + """Test that latest() accepts incoming stamp when current is None.""" + assert latest(None, Stamp(0, "option")) is True + + +def test_monotonic_counter_strictly_increases() -> None: + """Test that MonotonicCounter increments strictly by 1 each call.""" + clock = MonotonicCounter() + assert [clock(), clock(), clock()] == [1, 2, 3] From d164e8c9641ef03a14bfddab76cd25766eaecac8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:37:36 -0500 Subject: [PATCH 164/223] Agents(feat[store]): Add AgentStore + pure apply reducer + atomic JsonFile --- src/libtmux/experimental/agents/store.py | 332 +++++++++++++++++++++++ tests/experimental/agents/test_store.py | 72 +++++ 2 files changed, 404 insertions(+) create mode 100644 src/libtmux/experimental/agents/store.py create mode 100644 tests/experimental/agents/test_store.py diff --git a/src/libtmux/experimental/agents/store.py b/src/libtmux/experimental/agents/store.py new file mode 100644 index 000000000..3ab5af330 --- /dev/null +++ b/src/libtmux/experimental/agents/store.py @@ -0,0 +1,332 @@ +"""The durable agent-state store and its pure reducer. + +The store maps each pane to its current :class:`Agent` and the :class:`Stamp` +that produced it. The only mutator is :func:`apply`, a pure reducer that applies +the latest-wins guard. ``Storage``/``JsonFile`` persist the store atomically; +persistence is an optional seed in v1 (agents re-announce on reconnect). +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import json +import os +import pathlib +import tempfile +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.agents.merge import Stamp, latest +from libtmux.experimental.agents.state import Agent, AgentState + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + +@dataclass(frozen=True) +class Observed: + """An observed agent-state update from a signal source. + + Parameters + ---------- + pane_id : str + The tmux pane identifier (e.g., '%1'). + key : str + The unique key for this pane's agent. + name : str | None + The agent name (e.g., 'claude', 'codex'). + state : AgentState + The agent's current state. + stamp : Stamp + Logical clock tag ordering updates. + source : str + The signal source (e.g., 'option', 'osc'). + pid : int | None + The agent process ID, if known. + + Examples + -------- + >>> o = Observed( + ... pane_id="%1", key="%1", name="claude", + ... state=AgentState.RUNNING, stamp=Stamp(1, "option"), + ... source="option", pid=42 + ... ) + >>> o.pane_id + '%1' + """ + + pane_id: str + key: str + name: str | None + state: AgentState + stamp: Stamp + source: str + pid: int | None + + +@dataclass(frozen=True) +class Vanished: + """A pane that no longer exists (from reconcile or the health sweep). + + Parameters + ---------- + pane_id : str + The tmux pane identifier (e.g., '%1'). + + Examples + -------- + >>> v = Vanished(pane_id="%1") + >>> v.pane_id + '%1' + """ + + pane_id: str + + +@dataclass(frozen=True) +class AgentStore: + """The current agent per pane plus the stamp that produced it. + + Attributes + ---------- + agents : dict[str, Agent] + Maps pane_id to the pane's current Agent. + stamps : dict[str, Stamp] + Maps pane_id to the Stamp that produced its Agent. + + Examples + -------- + >>> store = AgentStore() + >>> store.agents + {} + >>> store.stamps + {} + """ + + agents: dict[str, Agent] = field(default_factory=dict) + stamps: dict[str, Stamp] = field(default_factory=dict) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to plain JSON-able data. + + Returns + ------- + dict[str, Any] + A dictionary with 'agents' and 'stamps' keys. + + Examples + -------- + >>> store = AgentStore() + >>> store.to_dict() + {'agents': {}, 'stamps': {}} + """ + return { + "agents": { + key: { + "pane_id": a.pane_id, + "key": a.key, + "name": a.name, + "state": a.state.value, + "since": a.since, + "source": a.source, + "pid": a.pid, + "alive": a.alive, + } + for key, a in self.agents.items() + }, + "stamps": {key: [s.counter, s.writer] for key, s in self.stamps.items()}, + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> AgentStore: + """Reconstruct from :meth:`to_dict` output. + + Parameters + ---------- + data : Mapping[str, Any] + A dictionary with 'agents' and 'stamps' keys from :meth:`to_dict`. + + Returns + ------- + AgentStore + A reconstructed store. + + Examples + -------- + >>> data = {'agents': {}, 'stamps': {}} + >>> AgentStore.from_dict(data) + AgentStore(agents={}, stamps={}) + """ + agents = { + key: Agent( + pane_id=a["pane_id"], + key=a["key"], + name=a["name"], + state=AgentState(a["state"]), + since=a["since"], + source=a["source"], + pid=a["pid"], + alive=a["alive"], + ) + for key, a in data.get("agents", {}).items() + } + stamps = { + key: Stamp(counter=v[0], writer=v[1]) + for key, v in data.get("stamps", {}).items() + } + return cls(agents=agents, stamps=stamps) + + +def apply(store: AgentStore, event: Observed | Vanished, *, now: float) -> AgentStore: + """Return a new store with *event* applied (pure; latest-wins for Observed). + + Parameters + ---------- + store : AgentStore + The current store. + event : Observed | Vanished + The event to apply. + now : float + The current timestamp (used as 'since' for the agent). + + Returns + ------- + AgentStore + A new store with the event applied. For :class:`Observed`, + applies the latest-wins guard via :func:`latest`. For + :class:`Vanished`, marks the agent as EXITED. + + Examples + -------- + >>> from libtmux.experimental.agents.merge import Stamp + >>> s = apply( + ... AgentStore(), + ... Observed("%1", "%1", "c", AgentState.RUNNING, + ... Stamp(1, "option"), "option", 7), + ... now=1.0 + ... ) + >>> s.agents["%1"].state + + """ + agents = dict(store.agents) + stamps = dict(store.stamps) + if isinstance(event, Vanished): + prev = agents.get(event.pane_id) + if prev is not None: + agents[event.pane_id] = dataclasses.replace( + prev, state=AgentState.EXITED, alive=False, since=now + ) + return AgentStore(agents=agents, stamps=stamps) + if not latest(stamps.get(event.pane_id), event.stamp): + return store + stamps[event.pane_id] = event.stamp + agents[event.pane_id] = Agent( + pane_id=event.pane_id, + key=event.key, + name=event.name, + state=event.state, + since=now, + source=event.source, + pid=event.pid, + alive=True, + ) + return AgentStore(agents=agents, stamps=stamps) + + +@t.runtime_checkable +class Storage(t.Protocol): + """A persistence sink for the store. + + Methods + ------- + load() + Return the persisted dict, or None if absent. + save(data) + Persist data durably. + + Examples + -------- + >>> class MockStorage: + ... def __init__(self): + ... self._data = None + ... def load(self): + ... return self._data + ... def save(self, data): + ... self._data = data + >>> storage = MockStorage() + >>> storage.save({"agents": {}, "stamps": {}}) + >>> storage.load() + {'agents': {}, 'stamps': {}} + """ + + def load(self) -> dict[str, t.Any] | None: + """Return the persisted dict, or ``None`` if absent.""" + ... + + def save(self, data: dict[str, t.Any]) -> None: + """Persist *data* durably.""" + ... + + +class JsonFile: + """An atomic JSON :class:`Storage` (temp file + ``os.replace`` + ``fsync``). + + Parameters + ---------- + path : str | pathlib.Path + The file path to persist to. + + Examples + -------- + >>> import tempfile, pathlib + >>> d = pathlib.Path(tempfile.mkdtemp()) + >>> sink = JsonFile(d / "x.json") + >>> sink.save({"agents": {}, "stamps": {}}) + >>> sink.load()["agents"] + {} + """ + + def __init__(self, path: str | pathlib.Path) -> None: + self._path = pathlib.Path(path) + + def load(self) -> dict[str, t.Any] | None: + """Return the persisted dict, or ``None`` if the file is absent. + + Returns + ------- + dict[str, Any] | None + The loaded data, or None if the file does not exist. + """ + try: + with self._path.open(encoding="utf-8") as handle: + return t.cast(dict[str, t.Any], json.load(handle)) + except FileNotFoundError: + return None + + def save(self, data: dict[str, t.Any]) -> None: + """Write *data* atomically (no partial file ever survives a crash). + + Parameters + ---------- + data : dict[str, Any] + The data to persist. + + Notes + ----- + Uses a temporary file + fsync + os.replace to ensure atomicity. + The temporary file is cleaned up on any exception. + """ + directory = self._path.parent + directory.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(directory), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(data, handle) + handle.flush() + os.fsync(handle.fileno()) + pathlib.Path(tmp).replace(self._path) + except BaseException: + with contextlib.suppress(OSError): + pathlib.Path(tmp).unlink() + raise diff --git a/tests/experimental/agents/test_store.py b/tests/experimental/agents/test_store.py new file mode 100644 index 000000000..d12035ed2 --- /dev/null +++ b/tests/experimental/agents/test_store.py @@ -0,0 +1,72 @@ +"""Tests for the durable store + reducer.""" + +from __future__ import annotations + +import json +import pathlib +import typing as t + +from libtmux.experimental.agents.merge import Stamp +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.store import ( + AgentStore, + JsonFile, + Observed, + Vanished, + apply, +) + + +def _observed(state: str, counter: int) -> Observed: + """Create an Observed event with default values.""" + return Observed( + pane_id="%1", + key="%1", + name="claude", + state=AgentState.from_signal(state), + stamp=Stamp(counter, "option"), + source="option", + pid=42, + ) + + +def test_apply_keeps_latest_and_ignores_stale() -> None: + """Test that stale updates don't override newer ones.""" + store = AgentStore() + store = apply(store, _observed("running", 2), now=10.0) + # a stale (lower-counter) update must not clobber the fresher one + store = apply(store, _observed("idle", 1), now=11.0) + assert store.agents["%1"].state is AgentState.RUNNING + + +def test_apply_advances_on_newer() -> None: + """Test that newer updates advance the agent state.""" + store = AgentStore() + store = apply(store, _observed("running", 1), now=10.0) + store = apply(store, _observed("awaiting_input", 2), now=11.0) + assert store.agents["%1"].state is AgentState.AWAITING_INPUT + + +def test_vanished_marks_exited() -> None: + """Test that a vanished pane marks the agent as exited.""" + store = AgentStore() + store = apply(store, _observed("running", 1), now=10.0) + store = apply(store, Vanished(pane_id="%1"), now=12.0) + assert store.agents["%1"].state is AgentState.EXITED + assert store.agents["%1"].alive is False + + +def test_jsonfile_atomic_roundtrip(tmp_path: pathlib.Path) -> None: + """Test that JsonFile saves and loads atomically without leaving temp files.""" + store = AgentStore() + store = apply(store, _observed("running", 1), now=10.0) + sink = JsonFile(tmp_path / "agents.json") + sink.save(store.to_dict()) + # a partial temp file must never be left behind + assert not list(tmp_path.glob("*.tmp")) + loaded_data = sink.load() + assert loaded_data is not None + restored = AgentStore.from_dict(loaded_data) + assert restored.agents["%1"].state is AgentState.RUNNING + # the saved file is valid JSON + assert json.loads((tmp_path / "agents.json").read_text())["agents"] From 10d5312b26b85a3ffc2c02bf2f45529c9b63afc4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:40:47 -0500 Subject: [PATCH 165/223] test_store.py(fix): Remove unused typing import --- tests/experimental/agents/test_store.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/experimental/agents/test_store.py b/tests/experimental/agents/test_store.py index d12035ed2..2d4590ed6 100644 --- a/tests/experimental/agents/test_store.py +++ b/tests/experimental/agents/test_store.py @@ -4,7 +4,6 @@ import json import pathlib -import typing as t from libtmux.experimental.agents.merge import Stamp from libtmux.experimental.agents.state import AgentState From 0b4636b004383b69856a303fc43577c65ae936a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:42:06 -0500 Subject: [PATCH 166/223] Agents(fix[store]): Add doctests on JsonFile.load/save --- src/libtmux/experimental/agents/store.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/libtmux/experimental/agents/store.py b/src/libtmux/experimental/agents/store.py index 3ab5af330..50e71ae2f 100644 --- a/src/libtmux/experimental/agents/store.py +++ b/src/libtmux/experimental/agents/store.py @@ -297,6 +297,11 @@ def load(self) -> dict[str, t.Any] | None: ------- dict[str, Any] | None The loaded data, or None if the file does not exist. + + Examples + -------- + >>> JsonFile("/nonexistent/path/x.json").load() is None + True """ try: with self._path.open(encoding="utf-8") as handle: @@ -316,6 +321,14 @@ def save(self, data: dict[str, t.Any]) -> None: ----- Uses a temporary file + fsync + os.replace to ensure atomicity. The temporary file is cleaned up on any exception. + + Examples + -------- + >>> import tempfile, pathlib + >>> sink = JsonFile(pathlib.Path(tempfile.mkdtemp()) / "x.json") + >>> sink.save({"agents": {}, "stamps": {}}) + >>> sink.load() + {'agents': {}, 'stamps': {}} """ directory = self._path.parent directory.mkdir(parents=True, exist_ok=True) From 697d9e79c2b581e3132516ebcad68872035cb0b6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:46:02 -0500 Subject: [PATCH 167/223] Agents(feat[signals]): Add OptionSignal + fragmented-OSC OscSignal parsers --- src/libtmux/experimental/agents/signals.py | 187 +++++++++++++++++++++ tests/experimental/agents/test_signals.py | 36 ++++ 2 files changed, 223 insertions(+) create mode 100644 src/libtmux/experimental/agents/signals.py create mode 100644 tests/experimental/agents/test_signals.py diff --git a/src/libtmux/experimental/agents/signals.py b/src/libtmux/experimental/agents/signals.py new file mode 100644 index 000000000..e3d2eb71a --- /dev/null +++ b/src/libtmux/experimental/agents/signals.py @@ -0,0 +1,187 @@ +"""The two channels an agent uses to report state. + +``OptionSignal`` reads tmux ``@agent_state`` user-options surfaced as +``%subscription-changed`` (local; ~1 s debounced, re-queryable). ``OscSignal`` +reads a bare ``OSC 3008`` escape out of ``%output`` (remote/SSH; instant), with a +per-pane accumulator because tmux delivers ``%output`` byte-fragmented. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from libtmux.experimental.agents.state import AgentState + +#: The ``refresh-client -B`` spec the monitor installs for the local channel. +SUBSCRIPTION = "agentstate:%*:#{@agent_state}" + +_SUB_RE = re.compile( + r"^%subscription-changed\s+agentstate\s+\S+\s+\S+\s+\S+\s+(?P%\d+)\s+:\s+(?P\S+)" +) +_OSC_RE = re.compile(rb"\033\]3008;([^\033\007]*)(?:\033\\|\007)") + + +@dataclass(frozen=True) +class Reading: + """One observed agent-state reading from a signal channel. + + Parameters + ---------- + pane_id : str + The pane identifier (e.g., ``%1``). + state : AgentState + The agent state observed. + name : str | None + The agent name, if present in the signal. + source : str + The signal source: ``"option"`` or ``"osc"``. + + Examples + -------- + >>> r = Reading(pane_id="%1", state=AgentState.RUNNING, name=None, + ... source="option") + >>> r.pane_id, r.state.value, r.source + ('%1', 'running', 'option') + """ + + pane_id: str + state: AgentState + name: str | None + source: str + + +def _parse_payload(payload: str) -> tuple[AgentState, str | None]: + """Parse an OSC/option payload like ``state=running`` (``name=`` optional). + + Parameters + ---------- + payload : str + The payload string, e.g., ``"state=running;name=claude"``. + + Returns + ------- + tuple[AgentState, str | None] + The agent state and optional name. + + Examples + -------- + >>> _parse_payload("state=running") + (, None) + >>> _parse_payload("state=idle;name=test") + (, 'test') + """ + state = AgentState.UNKNOWN + name: str | None = None + for part in payload.split(";"): + key, _, value = part.partition("=") + if key == "state": + state = AgentState.from_signal(value) + elif key == "name": + name = value or None + return state, name + + +class OptionSignal: + """Parse the local ``@agent_state`` subscription channel. + + Examples + -------- + >>> r = OptionSignal.parse( + ... "%subscription-changed agentstate $0 @0 1 %3 : running") + >>> r.pane_id, r.state.value + ('%3', 'running') + >>> OptionSignal.parse("%output %1 hi") is None + True + """ + + @staticmethod + def parse(notification_raw: str) -> Reading | None: + """Parse a ``%subscription-changed`` line; ``None`` if it isn't one. + + Parameters + ---------- + notification_raw : str + A raw tmux ``%subscription-changed`` notification line. + + Returns + ------- + Reading | None + A Reading if the line matches a subscription-changed pattern, + else ``None``. + + Examples + -------- + >>> r = OptionSignal.parse( + ... "%subscription-changed agentstate $0 @0 1 %3 : running") + >>> r.pane_id, r.state.value + ('%3', 'running') + >>> OptionSignal.parse("%output %1 hi") is None + True + """ + match = _SUB_RE.match(notification_raw) + if match is None: + return None + state = AgentState.from_signal(match.group("value")) + return Reading(match.group("pane"), state, None, "option") + + +class OscSignal: + r"""Reassemble ``OSC 3008`` agent-state escapes out of fragmented ``%output``. + + The class maintains per-pane byte buffers to handle tmux's byte-fragmented + ``%output`` delivery. Buffers are bounded to 4KB to prevent unbounded growth + from never-terminated escapes. + + Examples + -------- + >>> osc = OscSignal() + >>> osc.feed("%1", b"\033]3008;state=idle\033\\")[0].state.value + 'idle' + """ + + def __init__(self) -> None: + """Initialize the OSC signal parser with empty per-pane buffers.""" + self._buffers: dict[str, bytes] = {} + + def feed(self, pane_id: str, data: bytes) -> list[Reading]: + r"""Append *data* for *pane_id*; return a Reading per complete escape. + + This method accumulates bytes for a pane and scans for complete + ``OSC 3008`` escape sequences. Partial sequences are buffered for + the next call. + + Parameters + ---------- + pane_id : str + The pane identifier. + data : bytes + Bytes to append to the pane's buffer. + + Returns + ------- + list[Reading] + A list of Reading objects, one per complete escape found. + + Examples + -------- + >>> osc = OscSignal() + >>> readings = osc.feed("%1", b"\033]3008;state=awaiting_input\033\\") + >>> len(readings) + 1 + >>> readings[0].state.value + 'awaiting_input' + """ + buffer = self._buffers.get(pane_id, b"") + data + readings: list[Reading] = [] + while True: + match = _OSC_RE.search(buffer) + if match is None: + break + payload = match.group(1).decode(errors="replace") + state, name = _parse_payload(payload) + readings.append(Reading(pane_id, state, name, "osc")) + buffer = buffer[match.end() :] + # keep only a bounded tail so a never-terminated OSC can't grow unbounded + self._buffers[pane_id] = buffer[-4096:] + return readings diff --git a/tests/experimental/agents/test_signals.py b/tests/experimental/agents/test_signals.py new file mode 100644 index 000000000..3fa848bdb --- /dev/null +++ b/tests/experimental/agents/test_signals.py @@ -0,0 +1,36 @@ +"""Tests for the two agent-state signal parsers.""" + +from __future__ import annotations + +from libtmux.experimental.agents.signals import OptionSignal, OscSignal, Reading +from libtmux.experimental.agents.state import AgentState + + +def test_option_signal_parses_subscription_changed() -> None: + """Test parsing a valid subscription-changed notification.""" + line = "%subscription-changed agentstate $0 @0 1 %3 : running" + reading = OptionSignal.parse(line) + assert reading is not None + assert reading.pane_id == "%3" + assert reading.state is AgentState.RUNNING + assert reading.source == "option" + + +def test_option_signal_ignores_other_notifications() -> None: + """Test that non-subscription-changed notifications are ignored.""" + assert OptionSignal.parse("%output %1 hello") is None + assert OptionSignal.parse("%window-add @3") is None + + +def test_osc_signal_reassembles_fragmented_bytes() -> None: + """Test that OscSignal reassembles fragmented OSC escapes.""" + osc = OscSignal() + # the probe proved %output arrives byte-fragmented; feed one byte at a time + payload = b"\033]3008;state=awaiting_input\033\\" + readings: list[Reading] = [] + for i in range(len(payload)): + readings.extend(osc.feed("%2", payload[i : i + 1])) + assert len(readings) == 1 + assert readings[0].pane_id == "%2" + assert readings[0].state is AgentState.AWAITING_INPUT + assert readings[0].source == "osc" From 6dfbfbaba0b5bcf8168166dc97edb88bf073e3d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:50:28 -0500 Subject: [PATCH 168/223] Agents(feat[health]): Add is_alive process probe (PID-less remote safe) --- src/libtmux/experimental/agents/health.py | 32 +++++++++++++++++++++++ tests/experimental/agents/test_health.py | 25 ++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/libtmux/experimental/agents/health.py create mode 100644 tests/experimental/agents/test_health.py diff --git a/src/libtmux/experimental/agents/health.py b/src/libtmux/experimental/agents/health.py new file mode 100644 index 000000000..95bbcdeea --- /dev/null +++ b/src/libtmux/experimental/agents/health.py @@ -0,0 +1,32 @@ +"""Is the process behind a pane still alive. + +Local panes carry a ``pane_pid`` we can probe with ``os.kill(pid, 0)``. Remote +(SSH) panes are PID-less; this check never declares them dead — they expire on a +keepalive TTL owned by the monitor instead. +""" + +from __future__ import annotations + +import os + + +def is_alive(pid: int | None) -> bool: + """Return whether *pid* is a live process (``None`` → always alive). + + Examples + -------- + >>> import os + >>> is_alive(os.getpid()) + True + >>> is_alive(None) + True + """ + if pid is None: + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True # exists, owned by someone else + return True diff --git a/tests/experimental/agents/test_health.py b/tests/experimental/agents/test_health.py new file mode 100644 index 000000000..8bae9062d --- /dev/null +++ b/tests/experimental/agents/test_health.py @@ -0,0 +1,25 @@ +"""Tests for process-aliveness.""" + +from __future__ import annotations + +import os + +from libtmux.experimental.agents.health import is_alive + + +def test_self_is_alive() -> None: + """Process can probe itself as alive.""" + assert is_alive(os.getpid()) is True + + +def test_absent_pid_is_dead() -> None: + """Absent process is declared dead. + + PID 0x7FFFFFFF is almost certainly not a live process. + """ + assert is_alive(2_147_483_646) is False + + +def test_pidless_remote_never_declared_dead() -> None: + """PID-less remote agents never declared dead by this check.""" + assert is_alive(None) is True From 42cedf0897b7d3c1de97be885f42e38acb4a6cbd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:53:45 -0500 Subject: [PATCH 169/223] Agents(feat[tree]): Add derived pane tree + reconcile diff helpers --- src/libtmux/experimental/agents/tree.py | 84 +++++++++++++++++++++++++ tests/experimental/agents/test_tree.py | 34 ++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/libtmux/experimental/agents/tree.py create mode 100644 tests/experimental/agents/test_tree.py diff --git a/src/libtmux/experimental/agents/tree.py b/src/libtmux/experimental/agents/tree.py new file mode 100644 index 000000000..669505c80 --- /dev/null +++ b/src/libtmux/experimental/agents/tree.py @@ -0,0 +1,84 @@ +"""The live session→window→pane tree, derived from tmux (never the truth). + +A thin layer over ``ServerSnapshot.from_pane_rows``: the format to request, a +flattener to a ``{pane_id: PaneSnapshot}`` map, and a diff used by the monitor's +reconcile to synthesize the add/remove events the notification stream missed. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from libtmux.experimental.models.snapshots import PaneSnapshot, ServerSnapshot + +PANE_FORMAT: tuple[str, ...] = ( + "session_id", + "session_name", + "window_id", + "window_index", + "window_name", + "window_active", + "pane_id", + "pane_index", + "pane_active", + "pane_pid", + "pane_current_command", + "pane_title", +) + + +def panes_of(snapshot: ServerSnapshot) -> dict[str, PaneSnapshot]: + """Flatten a server snapshot to ``{pane_id: PaneSnapshot}``. + + Parameters + ---------- + snapshot : ServerSnapshot + A server snapshot containing sessions, windows, and panes. + + Returns + ------- + dict[str, PaneSnapshot] + A dictionary mapping pane IDs to PaneSnapshot objects. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import ServerSnapshot + >>> snap = ServerSnapshot.from_pane_rows( + ... [{"session_id": "$0", "window_id": "@0", "pane_id": "%1"}]) + >>> list(panes_of(snap)) + ['%1'] + """ + return { + pane.pane_id: pane + for session in snapshot.sessions + for window in session.windows + for pane in window.panes + } + + +def diff_panes( + old: dict[str, t.Any], new: dict[str, t.Any] +) -> tuple[list[str], list[str]]: + """Return ``(added_pane_ids, removed_pane_ids)`` between two pane maps. + + Parameters + ---------- + old : dict[str, t.Any] + The old pane map (by pane ID). + new : dict[str, t.Any] + The new pane map (by pane ID). + + Returns + ------- + tuple[list[str], list[str]] + A tuple of (added_pane_ids, removed_pane_ids). + + Examples + -------- + >>> diff_panes({"%1": 1, "%2": 1}, {"%2": 1, "%3": 1}) + (['%3'], ['%1']) + """ + added = [pid for pid in new if pid not in old] + removed = [pid for pid in old if pid not in new] + return added, removed diff --git a/tests/experimental/agents/test_tree.py b/tests/experimental/agents/test_tree.py new file mode 100644 index 000000000..6dcc16507 --- /dev/null +++ b/tests/experimental/agents/test_tree.py @@ -0,0 +1,34 @@ +"""Tests for the derived tmux tree helpers.""" + +from __future__ import annotations + +from libtmux.experimental.agents.tree import diff_panes, panes_of +from libtmux.experimental.models.snapshots import ServerSnapshot + + +def _snap(pane_ids: list[str]) -> ServerSnapshot: + rows = [ + { + "session_id": "$0", + "window_id": "@0", + "window_index": "0", + "pane_id": pid, + "pane_index": str(i), + } + for i, pid in enumerate(pane_ids) + ] + return ServerSnapshot.from_pane_rows(rows) + + +def test_panes_of_flattens() -> None: + """Test that panes_of flattens a snapshot into a pane map.""" + assert set(panes_of(_snap(["%1", "%2"]))) == {"%1", "%2"} + + +def test_diff_panes_reports_added_and_removed() -> None: + """Test that diff_panes reports added and removed pane IDs.""" + old = panes_of(_snap(["%1", "%2"])) + new = panes_of(_snap(["%2", "%3"])) + added, removed = diff_panes(old, new) + assert added == ["%3"] + assert removed == ["%1"] From 45a63cd8e3692bc46a01992686237b90aef5f638 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:57:50 -0500 Subject: [PATCH 170/223] Ops(feat[refresh_client]): Add typed -B subscription + -C size --- .../experimental/ops/_ops/refresh_client.py | 34 ++++++++++++- .../ops/test_refresh_client_subscribe.py | 48 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/experimental/ops/test_refresh_client_subscribe.py diff --git a/src/libtmux/experimental/ops/_ops/refresh_client.py b/src/libtmux/experimental/ops/_ops/refresh_client.py index 624d81d15..87cfb72e3 100644 --- a/src/libtmux/experimental/ops/_ops/refresh_client.py +++ b/src/libtmux/experimental/ops/_ops/refresh_client.py @@ -15,11 +15,26 @@ class RefreshClient(Operation[AckResult]): """Refresh a client. Produces no output (:class:`AckResult`). + Parameters + ---------- + subscribe : str, optional + Format spec passed to ``-B``; subscribes the control client to a + named format notification. + size : str, optional + Geometry passed to ``-C`` (e.g. ``"200x50"``); overrides the + client's reported size. + Examples -------- >>> from libtmux.experimental.ops._types import ClientName >>> RefreshClient(target=ClientName("/dev/pts/3")).render() ('refresh-client', '-t', '/dev/pts/3') + >>> op = RefreshClient( + ... target=ClientName("/dev/pts/3"), + ... subscribe="agentstate:%*:#{@agent_state}", + ... ) + >>> op.render() + ('refresh-client', '-t', '/dev/pts/3', '-B', 'agentstate:%*:#{@agent_state}') """ kind = "refresh_client" @@ -29,6 +44,21 @@ class RefreshClient(Operation[AckResult]): safety = "mutating" effects = Effects(idempotent=True) + subscribe: str | None = None + size: str | None = None + def args(self, *, version: str | None = None) -> tuple[str, ...]: - """No positional arguments beyond the target client.""" - return () + """Emit ``-B `` and/or ``-C `` when set. + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> RefreshClient(target=ClientName("/dev/pts/3"), size="200x50").args() + ('-C', '200x50') + """ + out: list[str] = [] + if self.subscribe is not None: + out += ["-B", self.subscribe] + if self.size is not None: + out += ["-C", self.size] + return tuple(out) diff --git a/tests/experimental/ops/test_refresh_client_subscribe.py b/tests/experimental/ops/test_refresh_client_subscribe.py new file mode 100644 index 000000000..886c88d50 --- /dev/null +++ b/tests/experimental/ops/test_refresh_client_subscribe.py @@ -0,0 +1,48 @@ +"""Tests for refresh-client -B/-C support.""" + +from __future__ import annotations + +from libtmux.experimental.ops._ops.refresh_client import RefreshClient +from libtmux.experimental.ops._types import ClientName + + +def test_subscribe_emits_dash_b() -> None: + """subscribe= field emits -B after the target.""" + op = RefreshClient( + target=ClientName("/dev/pts/3"), subscribe="agentstate:%*:#{@agent_state}" + ) + assert op.render() == ( + "refresh-client", + "-t", + "/dev/pts/3", + "-B", + "agentstate:%*:#{@agent_state}", + ) + + +def test_size_emits_dash_c() -> None: + """size= field emits -C after the target.""" + op = RefreshClient(target=ClientName("/dev/pts/3"), size="200x50") + assert op.render() == ("refresh-client", "-t", "/dev/pts/3", "-C", "200x50") + + +def test_no_extra_args_by_default() -> None: + """Default (no subscribe/size) renders only the target flag.""" + op = RefreshClient(target=ClientName("/dev/pts/3")) + assert op.render() == ("refresh-client", "-t", "/dev/pts/3") + + +def test_subscribe_and_size_order() -> None: + """Both set yields -B before -C.""" + op = RefreshClient( + target=ClientName("/dev/pts/3"), subscribe="s:%*:#{@x}", size="200x50" + ) + assert op.render() == ( + "refresh-client", + "-t", + "/dev/pts/3", + "-B", + "s:%*:#{@x}", + "-C", + "200x50", + ) From 4a5e4037040cf8ece86d54cbd64e9eca02c11062 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:12:04 -0500 Subject: [PATCH 171/223] Engines(fix[async_control_mode]): Reset sticky attach on reconnect why: A reconnect left _attached_session set, so the next _ensure_attached call skipped re-attaching and %output was silently missing. what: - Pin contract with test_reset_attach_clears_flag (Task 10) - Add _attached_session to _StreamEngine Protocol; drop type: ignore --- src/libtmux/experimental/mcp/events.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index de22a01d5..76bed7846 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -122,6 +122,8 @@ class _StreamEngine(t.Protocol): against this narrower protocol after the :func:`_supports_stream` guard. """ + _attached_session: str | None + async def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command.""" ... @@ -404,7 +406,7 @@ async def _ensure_attached(engine: _StreamEngine, session_id: str) -> None: detail = " ".join(result.stderr) or "attach-session failed" msg = f"cannot watch {session_id}: {detail}" raise RuntimeError(msg) - engine._attached_session = session_id # type: ignore[attr-defined] + engine._attached_session = session_id def _register_monitor(mcp: FastMCP, engine: _StreamEngine) -> None: From b5eaec249a36e8bbf0bbf94c8590554815c8dc4d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:18:02 -0500 Subject: [PATCH 172/223] Agents(feat[monitor]): Add AgentMonitor ingest + store wiring --- src/libtmux/experimental/agents/monitor.py | 314 +++++++++++++++++++++ tests/experimental/agents/test_monitor.py | 42 +++ 2 files changed, 356 insertions(+) create mode 100644 src/libtmux/experimental/agents/monitor.py create mode 100644 tests/experimental/agents/test_monitor.py diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py new file mode 100644 index 000000000..351529cce --- /dev/null +++ b/src/libtmux/experimental/agents/monitor.py @@ -0,0 +1,314 @@ +"""The AgentMonitor: integrates signals, store, and the engine event loop. + +Every notification from the tmux control-mode engine passes through +:meth:`AgentMonitor.ingest`, a synchronous reducer that classifies the raw +string, feeds the appropriate signal parser, and writes the result into the +coalescing :class:`~libtmux.experimental.agents.store.AgentStore` via +:func:`~libtmux.experimental.agents.store.apply`. + +The async half (:meth:`AgentMonitor.start`, :meth:`AgentMonitor.stop`, +:meth:`AgentMonitor.reconcile`) wires the live engine subscribe loop and +performs a periodic full-pane reconciliation to catch panes the stream missed. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import time +import typing as t + +from libtmux.experimental.agents.merge import MonotonicCounter, Stamp +from libtmux.experimental.agents.signals import SUBSCRIPTION, OptionSignal, OscSignal +from libtmux.experimental.agents.store import ( + AgentStore, + Observed, + Storage, + Vanished, + apply, +) +from libtmux.experimental.agents.tree import PANE_FORMAT, diff_panes, panes_of + +if t.TYPE_CHECKING: + from libtmux.experimental.agents.merge import Clock + from libtmux.experimental.agents.signals import Reading + from libtmux.experimental.agents.state import Agent + from libtmux.experimental.agents.store import AgentStore + from libtmux.experimental.models.snapshots import ServerSnapshot + +logger = logging.getLogger(__name__) + +# The separator between tmux format fields in list-panes output rows. +_SEP = "\t" +# Pre-build the -F format string from the PANE_FORMAT tuple once. +_PANE_FORMAT_STR = _SEP.join(f"#{{{field}}}" for field in PANE_FORMAT) + + +class AgentMonitor: + """Wire signals, the coalescing store, and the engine event loop. + + Parameters + ---------- + engine : object + An async tmux engine with ``run``, ``subscribe``, ``add_subscription``, + and ``set_attach_targets`` methods. + sink : Storage or None + Optional persistence sink; if present and non-empty on startup the + store is seeded from it. + clock : Clock or None + Logical clock for stamping updates. Defaults to + :class:`~libtmux.experimental.agents.merge.MonotonicCounter`. + + Examples + -------- + >>> class _Fake: + ... async def run(self, request): ... + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> mon = AgentMonitor(_Fake()) + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + >>> mon.agents[0].pane_id + '%1' + """ + + def __init__( + self, + engine: t.Any, + *, + sink: Storage | None = None, + clock: Clock | None = None, + ) -> None: + self._engine = engine + self._sink = sink + self._clock: Clock = clock or MonotonicCounter() + self._osc = OscSignal() + self._prev_panes: dict[str, t.Any] = {} + self._task: asyncio.Task[None] | None = None + + # Seed the store from a persistent sink when one is provided. + if sink is not None: + data = sink.load() + if data: + self._store = AgentStore.from_dict(data) + else: + self._store = AgentStore() + else: + self._store = AgentStore() + + # ------------------------------------------------------------------ + # Synchronous reducer pipeline + # ------------------------------------------------------------------ + + def ingest(self, notification_raw: str) -> None: + """Classify one control-mode notification and update the agent store. + + This method is **synchronous** so that unit tests can drive it + directly without a live engine. The async drain loop calls it per + notification. + + - ``%output `` → fed to :class:`OscSignal`. + - Everything else → tried against :class:`OptionSignal`. + + Parameters + ---------- + notification_raw : str + A raw tmux control-mode notification string. + + Examples + -------- + >>> class _Fake: + ... async def run(self, request): ... + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> mon = AgentMonitor(_Fake()) + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %2 : idle") + >>> mon.agents[0].state.value + 'idle' + """ + if notification_raw.startswith("%output "): + # Split on first two spaces: ["%output", pane_id, rest] + parts = notification_raw.split(" ", 2) + if len(parts) < 3: + return + _tag, pane_id, rest = parts + for reading in self._osc.feed(pane_id, rest.encode()): + self._observe(reading) + else: + opt_reading = OptionSignal.parse(notification_raw) + if opt_reading is not None: + self._observe(opt_reading) + + def _observe(self, reading: Reading) -> None: + """Apply one parsed reading to the store (latest-wins via :func:`apply`). + + Parameters + ---------- + reading : Reading + A parsed signal reading from :class:`OptionSignal` or + :class:`OscSignal`. + """ + observed = Observed( + pane_id=reading.pane_id, + key=reading.pane_id, + name=reading.name, + state=reading.state, + stamp=Stamp(self._clock(), reading.source), + source=reading.source, + pid=None, + ) + self._store = apply(self._store, observed, now=time.monotonic()) + if self._sink is not None: + self._sink.save(self._store.to_dict()) + logger.debug( + "observed agent state %s on pane %s from %s", + reading.state.value, + reading.pane_id, + reading.source, + ) + + # ------------------------------------------------------------------ + # Public read API + # ------------------------------------------------------------------ + + @property + def agents(self) -> list[Agent]: + """A snapshot of all currently tracked agents. + + Returns + ------- + list[Agent] + One :class:`~libtmux.experimental.agents.state.Agent` per + monitored pane. + + Examples + -------- + >>> class _Fake: + ... async def run(self, request): ... + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> mon = AgentMonitor(_Fake()) + >>> mon.agents + [] + >>> mon.ingest( + ... "%subscription-changed agentstate $0 @0 1 %3 : running") + >>> len(mon.agents) + 1 + """ + return list(self._store.agents.values()) + + def status(self) -> dict[str, t.Any]: + """Return a lightweight health/stats dict for the monitor. + + Returns + ------- + dict[str, Any] + ``agents`` — number of tracked agents; + ``generation`` — engine generation counter (0 if not exposed). + + Examples + -------- + >>> class _Fake: + ... async def run(self, request): ... + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> AgentMonitor(_Fake()).status() + {'agents': 0, 'generation': 0} + """ + return { + "agents": len(self._store.agents), + "generation": getattr(self._engine, "_generation", 0), + } + + # ------------------------------------------------------------------ + # Async lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Install the subscription and begin draining the engine event stream. + + Spawns a background task that feeds every notification into + :meth:`ingest`. Also attempts an initial :meth:`reconcile` to + synchronise against the current pane tree. + """ + self._engine.add_subscription(SUBSCRIPTION) + self._task = asyncio.get_running_loop().create_task(self._drain()) + await self.reconcile() + + async def stop(self) -> None: + """Cancel the drain task and optionally flush the sink.""" + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + if self._sink is not None: + self._sink.save(self._store.to_dict()) + + async def reconcile(self) -> None: + """Reconcile the store against the live pane tree. + + Runs ``list-panes -a -F`` via the engine, diffs the result against + the previously seen pane set, and applies :class:`Vanished` for any + panes that have disappeared. This is defensive: any error from the + engine is caught and logged so the monitor stays alive. + """ + try: + from libtmux.experimental.engines.base import CommandRequest + from libtmux.experimental.models.snapshots import ServerSnapshot + + fmt_str = _PANE_FORMAT_STR + req = CommandRequest.from_args("list-panes", "-a", "-F", fmt_str) + result = await self._engine.run(req) + rows = _parse_pane_rows(result.stdout) + snapshot: ServerSnapshot = ServerSnapshot.from_pane_rows(rows) + current_panes = panes_of(snapshot) + _added, removed = diff_panes(self._prev_panes, current_panes) + for pane_id in removed: + self._store = apply( + self._store, + Vanished(pane_id=pane_id), + now=time.monotonic(), + ) + self._prev_panes = dict(current_panes) + except Exception: + logger.debug("reconcile skipped — engine call failed", exc_info=True) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + async def _drain(self) -> None: + """Background task: forward every engine notification to :meth:`ingest`.""" + async for note in self._engine.subscribe(): + self.ingest(note.raw) + + +def _parse_pane_rows( + stdout: tuple[str, ...], +) -> list[dict[str, str]]: + """Parse ``list-panes -F`` tab-separated output into field dicts. + + Parameters + ---------- + stdout : tuple[str, ...] + Lines from the engine's command result stdout. + + Returns + ------- + list[dict[str, str]] + One dict per non-empty line, keyed by :data:`PANE_FORMAT` fields. + """ + rows: list[dict[str, str]] = [] + fields = PANE_FORMAT + for line in stdout: + if not line: + continue + parts = line.split(_SEP) + # zip stops at the shorter sequence — tolerate truncated rows + rows.append(dict(zip(fields, parts, strict=False))) + return rows diff --git a/tests/experimental/agents/test_monitor.py b/tests/experimental/agents/test_monitor.py new file mode 100644 index 000000000..ce2160e58 --- /dev/null +++ b/tests/experimental/agents/test_monitor.py @@ -0,0 +1,42 @@ +"""Unit tests for AgentMonitor.ingest (no live tmux).""" + +from __future__ import annotations + +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import AgentState + + +class _FakeEngine: + async def run(self, request: object) -> None: ... + + async def subscribe(self) -> None: ... + + def add_subscription(self, spec: object) -> None: ... + + def set_attach_targets(self, ids: object) -> None: ... + + +def test_ingest_option_line_updates_agent() -> None: + """Option-channel %subscription-changed maps to a store entry.""" + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + by_pane = {a.pane_id: a for a in mon.agents} + assert by_pane["%1"].state is AgentState.RUNNING + + +def test_ingest_osc_output_updates_agent() -> None: + r"""OSC %output line feeds the OscSignal and lands in the store.""" + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%output %2 \033]3008;state=awaiting_input\033\\") + by_pane = {a.pane_id: a for a in mon.agents} + assert by_pane["%2"].state is AgentState.AWAITING_INPUT + + +def test_stale_does_not_clobber() -> None: + """Second (newer counter) option update beats the first — latest-wins.""" + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + # newest wins; both via the option writer so the second (newer counter) wins + by_pane = {a.pane_id: a for a in mon.agents} + assert by_pane["%1"].state is AgentState.IDLE From 3ea59c4983d9f82b2d935d38b144c97513f1b520 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:22:40 -0500 Subject: [PATCH 173/223] Agents(feat[hooks]): Add shared emitter (local set-option / remote OSC to tty) --- pyproject.toml | 1 + .../experimental/agents/hooks/__init__.py | 3 + src/libtmux/experimental/agents/hooks/emit.py | 106 ++++++++++++++++++ tests/experimental/agents/hooks/__init__.py | 3 + tests/experimental/agents/hooks/test_emit.py | 28 +++++ 5 files changed, 141 insertions(+) create mode 100644 src/libtmux/experimental/agents/hooks/__init__.py create mode 100644 src/libtmux/experimental/agents/hooks/emit.py create mode 100644 tests/experimental/agents/hooks/__init__.py create mode 100644 tests/experimental/agents/hooks/test_emit.py diff --git a/pyproject.toml b/pyproject.toml index 90ba377af..fe1c1eacf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ libtmux = "libtmux.pytest_plugin" # Experimental typed-ops MCP server (stdio). Requires the `mcp` extra; # `main` prints an install hint and exits non-zero when fastmcp is absent. libtmux-engine-mcp = "libtmux.experimental.mcp:main" +libtmux-agent-emit = "libtmux.experimental.agents.hooks.emit:main" [project.optional-dependencies] mcp = [ diff --git a/src/libtmux/experimental/agents/hooks/__init__.py b/src/libtmux/experimental/agents/hooks/__init__.py new file mode 100644 index 000000000..d45185cce --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/__init__.py @@ -0,0 +1,3 @@ +"""Agent-side hook emitters + installers.""" + +from __future__ import annotations diff --git a/src/libtmux/experimental/agents/hooks/emit.py b/src/libtmux/experimental/agents/hooks/emit.py new file mode 100644 index 000000000..0e0a6be65 --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/emit.py @@ -0,0 +1,106 @@ +"""Emit an agent-state signal from inside an agent's lifecycle hook. + +Local (tmux reachable): write the ``@agent_state`` pane option. Remote (SSH): +write an ``OSC 3008`` escape to ``/dev/tty`` -- NOT stdout, which agent hooks +pipe/null -- so it reaches the pane pty and travels over SSH into tmux %output. +""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import sys +import typing as t + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +def emit( + state: str, + *, + name: str | None = None, + runner: t.Callable[..., t.Any] = subprocess.run, + tty_path: str = "/dev/tty", + env: Mapping[str, str] | None = None, +) -> None: + """Signal *state* for the current pane (local set-option, else remote OSC). + + Parameters + ---------- + state : str + Agent state string (e.g. ``"running"``, ``"idle"``). + name : str or None + Optional agent name to emit alongside the state. + runner : callable + Subprocess runner; injectable for tests. Default: ``subprocess.run``. + tty_path : str + Path to the controlling terminal for the remote OSC path. + Default: ``"/dev/tty"``. + env : Mapping[str, str] or None + Environment mapping used to detect ``$TMUX`` / ``$TMUX_PANE``. + Default: ``os.environ``. + + Examples + -------- + >>> calls = [] + >>> emit("running", runner=lambda a, **k: calls.append(a), + ... env={"TMUX": "x", "TMUX_PANE": "%1"}) + >>> calls[0][:2] + ['tmux', 'set-option'] + """ + environ = os.environ if env is None else env + pane = environ.get("TMUX_PANE") + if environ.get("TMUX") and pane: + runner( + ["tmux", "set-option", "-p", "-t", pane, "@agent_state", state], + check=False, + ) + if name: + runner( + ["tmux", "set-option", "-p", "-t", pane, "@agent_name", name], + check=False, + ) + return + payload = f"state={state}" + if name: + payload += f";name={name}" + escape = f"\033]3008;{payload}\033\\".encode() + with pathlib.Path(tty_path).open("wb", buffering=0) as tty: + tty.write(escape) + + +def main(argv: Sequence[str] | None = None) -> int: + """Console entry point: ``libtmux-agent-emit [--name NAME]``. + + Parameters + ---------- + argv : Sequence[str] or None + Argument list; defaults to ``sys.argv[1:]`` when ``None``. + + Returns + ------- + int + Exit code: ``0`` on success, ``2`` when no arguments are provided. + + Examples + -------- + >>> from libtmux.experimental.agents.hooks.emit import main + >>> main([]) + 2 + + The success path calls :func:`emit`, which opens ``/dev/tty`` or invokes + ``tmux``; its coverage lives in + ``tests/experimental/agents/hooks/test_emit.py``. + """ + args = list(sys.argv[1:] if argv is None else argv) + if not args: + return 2 + state = args[0] + name: str | None = None + if "--name" in args: + idx = args.index("--name") + name = args[idx + 1] + emit(state, name=name) + return 0 diff --git a/tests/experimental/agents/hooks/__init__.py b/tests/experimental/agents/hooks/__init__.py new file mode 100644 index 000000000..578299bbf --- /dev/null +++ b/tests/experimental/agents/hooks/__init__.py @@ -0,0 +1,3 @@ +"""Tests for agent-side hook emitters.""" + +from __future__ import annotations diff --git a/tests/experimental/agents/hooks/test_emit.py b/tests/experimental/agents/hooks/test_emit.py new file mode 100644 index 000000000..f8ac443a6 --- /dev/null +++ b/tests/experimental/agents/hooks/test_emit.py @@ -0,0 +1,28 @@ +"""Tests for the shared agent-state emitter.""" + +from __future__ import annotations + +import pathlib + +from libtmux.experimental.agents.hooks.emit import emit + + +def test_local_uses_set_option() -> None: + """Local path (TMUX + TMUX_PANE set) calls tmux set-option via runner.""" + calls: list[list[str]] = [] + emit( + "running", + runner=lambda argv, **kw: calls.append(argv), + env={"TMUX": "/tmp/x,1,0", "TMUX_PANE": "%4"}, + ) + assert calls[0][:5] == ["tmux", "set-option", "-p", "-t", "%4"] + assert calls[0][5:] == ["@agent_state", "running"] + + +def test_remote_writes_osc_to_tty(tmp_path: pathlib.Path) -> None: + """Remote path (no TMUX) writes OSC 3008 escape bytes to tty_path.""" + tty = tmp_path / "tty" + tty.write_bytes(b"") + emit("idle", tty_path=str(tty), env={}) # no TMUX → remote path + data = tty.read_bytes() + assert b"\033]3008;state=idle\033\\" in data From ec79ca56bd7243901d9136b8f1a18d28f8f4d6d8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:29:11 -0500 Subject: [PATCH 174/223] =?UTF-8?q?Agents(feat[hooks]):=20Add=20AgentHook?= =?UTF-8?q?=20protocol=20+=20registry=20+=20event=E2=86=92state=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Tasks 14/15 need a stable protocol + registry to import from; the monitor needs the canonical event→state map to translate hook names. what: - Add EVENT_STATE canonical lifecycle-event→state dict in base.py - Add AgentHook runtime_checkable Protocol (name/detect/install/uninstall/status) - Add registry() + get() with lazy imports to avoid import cycles - Add stub ClaudeCodeHook (claude.py) + CodexHook (codex.py) for Tasks 14/15 - Add test_registry.py (3 tests: canonical map, registry members, KeyError) --- src/libtmux/experimental/agents/hooks/base.py | 98 +++++++++++++++++++ .../experimental/agents/hooks/claude.py | 71 ++++++++++++++ .../experimental/agents/hooks/codex.py | 71 ++++++++++++++ .../experimental/agents/hooks/registry.py | 73 ++++++++++++++ .../agents/hooks/test_registry.py | 26 +++++ 5 files changed, 339 insertions(+) create mode 100644 src/libtmux/experimental/agents/hooks/base.py create mode 100644 src/libtmux/experimental/agents/hooks/claude.py create mode 100644 src/libtmux/experimental/agents/hooks/codex.py create mode 100644 src/libtmux/experimental/agents/hooks/registry.py create mode 100644 tests/experimental/agents/hooks/test_registry.py diff --git a/src/libtmux/experimental/agents/hooks/base.py b/src/libtmux/experimental/agents/hooks/base.py new file mode 100644 index 000000000..cf973ee11 --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/base.py @@ -0,0 +1,98 @@ +"""AgentHook protocol and canonical lifecycle-event → state map. + +The ``EVENT_STATE`` map translates neutral event names emitted by agent +lifecycle hooks into the :data:`AgentState` string vocabulary understood +by the monitor. ``AgentHook`` is the protocol every hook installer must +satisfy. +""" + +from __future__ import annotations + +import typing as t + +#: Canonical map from a neutral lifecycle event name to an agent-state string. +#: +#: Keys are the event names that agent hooks fire (e.g. as hook script names); +#: values are the :class:`~libtmux.experimental.agents.state.AgentState` +#: string representations the monitor stores. +#: +#: Examples +#: -------- +#: >>> EVENT_STATE["turn_start"] +#: 'running' +#: >>> EVENT_STATE["needs_approval"] +#: 'awaiting_input' +#: >>> EVENT_STATE["turn_end"] +#: 'awaiting_input' +#: >>> EVENT_STATE["session_start"] +#: 'idle' +EVENT_STATE: dict[str, str] = { + "turn_start": "running", + "needs_approval": "awaiting_input", + "turn_end": "awaiting_input", + "session_start": "idle", +} + + +@t.runtime_checkable +class AgentHook(t.Protocol): + """Protocol every hook installer must satisfy. + + A hook object represents one agent's lifecycle-hook integration. It + knows how to detect whether its hooks are already installed (and at + what version), install them, uninstall them, and report current status. + + Examples + -------- + >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + >>> hook = ClaudeCodeHook() + >>> isinstance(hook, AgentHook) + True + >>> hook.name + 'claude' + """ + + #: Short machine identifier for this hook (e.g. ``"claude"``). + name: str + + def detect(self) -> bool: + """Return ``True`` when the agent binary / config dir is present. + + Examples + -------- + >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + >>> isinstance(ClaudeCodeHook().detect(), bool) + True + """ + ... + + def install(self) -> None: + """Write hook scripts into the agent's config directory. + + Examples + -------- + >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + >>> ClaudeCodeHook().install() # no-op on stub + """ + ... + + def uninstall(self) -> None: + """Remove hook scripts from the agent's config directory. + + Examples + -------- + >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + >>> ClaudeCodeHook().uninstall() # no-op on stub + """ + ... + + def status(self) -> str: + """Return ``"installed"``, ``"outdated"``, or ``"absent"``. + + Examples + -------- + >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + >>> ClaudeCodeHook().status() in {"installed", "outdated", "absent"} + True + """ + ... diff --git a/src/libtmux/experimental/agents/hooks/claude.py b/src/libtmux/experimental/agents/hooks/claude.py new file mode 100644 index 000000000..9ea1a9f30 --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/claude.py @@ -0,0 +1,71 @@ +"""Stub for ClaudeCodeHook — completed in Task 14. + +This module exists so that :mod:`libtmux.experimental.agents.hooks.registry` +can import :class:`ClaudeCodeHook` without error while Task 14 is not yet +merged. The real implementation (config-dir detection, hook-script writing, +status inspection) will replace this body in Task 14. +""" + +from __future__ import annotations + + +class ClaudeCodeHook: + """Lifecycle-hook installer for Claude Code. + + .. note:: + This is a **stub** completed in Task 14. All methods are no-ops; + :meth:`detect` returns ``False`` and :meth:`status` returns + ``"absent"`` until the real implementation lands. + + Examples + -------- + >>> ClaudeCodeHook().name + 'claude' + >>> ClaudeCodeHook().detect() + False + >>> ClaudeCodeHook().status() + 'absent' + """ + + #: Short machine identifier understood by the registry. + name: str = "claude" + + def detect(self) -> bool: + """Return ``False`` — stub; real detection added in Task 14. + + Examples + -------- + >>> ClaudeCodeHook().detect() + False + """ + # TODO(Task 14): detect ~/.claude or `claude` binary presence + return False + + def install(self) -> None: + """No-op — stub; real installer added in Task 14. + + Examples + -------- + >>> ClaudeCodeHook().install() + """ + # TODO(Task 14): write hook scripts into ~/.claude/hooks/ + + def uninstall(self) -> None: + """No-op — stub; real uninstaller added in Task 14. + + Examples + -------- + >>> ClaudeCodeHook().uninstall() + """ + # TODO(Task 14): remove hook scripts from ~/.claude/hooks/ + + def status(self) -> str: + """Return ``"absent"`` — stub; real status check added in Task 14. + + Examples + -------- + >>> ClaudeCodeHook().status() + 'absent' + """ + # TODO(Task 14): inspect hook scripts and return installed/outdated/absent + return "absent" diff --git a/src/libtmux/experimental/agents/hooks/codex.py b/src/libtmux/experimental/agents/hooks/codex.py new file mode 100644 index 000000000..3b4c85ce0 --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/codex.py @@ -0,0 +1,71 @@ +"""Stub for CodexHook — completed in Task 15. + +This module exists so that :mod:`libtmux.experimental.agents.hooks.registry` +can import :class:`CodexHook` without error while Task 15 is not yet merged. +The real implementation (config-dir detection, hook-script writing, status +inspection) will replace this body in Task 15. +""" + +from __future__ import annotations + + +class CodexHook: + """Lifecycle-hook installer for OpenAI Codex CLI. + + .. note:: + This is a **stub** completed in Task 15. All methods are no-ops; + :meth:`detect` returns ``False`` and :meth:`status` returns + ``"absent"`` until the real implementation lands. + + Examples + -------- + >>> CodexHook().name + 'codex' + >>> CodexHook().detect() + False + >>> CodexHook().status() + 'absent' + """ + + #: Short machine identifier understood by the registry. + name: str = "codex" + + def detect(self) -> bool: + """Return ``False`` — stub; real detection added in Task 15. + + Examples + -------- + >>> CodexHook().detect() + False + """ + # TODO(Task 15): detect ~/.codex or `codex` binary presence + return False + + def install(self) -> None: + """No-op — stub; real installer added in Task 15. + + Examples + -------- + >>> CodexHook().install() + """ + # TODO(Task 15): write hook scripts into ~/.codex/hooks/ + + def uninstall(self) -> None: + """No-op — stub; real uninstaller added in Task 15. + + Examples + -------- + >>> CodexHook().uninstall() + """ + # TODO(Task 15): remove hook scripts from ~/.codex/hooks/ + + def status(self) -> str: + """Return ``"absent"`` — stub; real status check added in Task 15. + + Examples + -------- + >>> CodexHook().status() + 'absent' + """ + # TODO(Task 15): inspect hook scripts and return installed/outdated/absent + return "absent" diff --git a/src/libtmux/experimental/agents/hooks/registry.py b/src/libtmux/experimental/agents/hooks/registry.py new file mode 100644 index 000000000..53a73f26b --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/registry.py @@ -0,0 +1,73 @@ +"""AgentHook installer registry. + +All known :class:`~libtmux.experimental.agents.hooks.base.AgentHook` +installers are listed via :func:`registry` or fetched by name via +:func:`get`. Imports inside those functions stay lazy to avoid cycles. +""" + +from __future__ import annotations + +from libtmux.experimental.agents.hooks.base import AgentHook + + +def registry() -> list[AgentHook]: + """Return one instance of every known hook installer. + + Imports are performed lazily here to break any potential import cycles + between this registry module and individual hook modules. + + Returns + ------- + list[AgentHook] + Ordered list of hook installer instances; currently + ``[ClaudeCodeHook(), CodexHook()]``. + + Examples + -------- + >>> hooks = registry() + >>> {h.name for h in hooks} >= {"claude", "codex"} + True + """ + # Lazy imports — keep here to avoid import cycles when hook modules + # grow richer dependencies (Tasks 14/15). + from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + from libtmux.experimental.agents.hooks.codex import CodexHook + + return [ClaudeCodeHook(), CodexHook()] + + +def get(name: str) -> AgentHook: + """Return the hook installer for *name*, or raise :exc:`KeyError`. + + Parameters + ---------- + name : str + The hook's :attr:`~libtmux.experimental.agents.hooks.base.AgentHook.name` + (e.g. ``"claude"`` or ``"codex"``). + + Returns + ------- + AgentHook + The matching hook installer instance. + + Raises + ------ + KeyError + When no hook with *name* is registered. + + Examples + -------- + >>> get("claude").name + 'claude' + >>> get("codex").name + 'codex' + >>> try: + ... get("unknown") + ... except KeyError: + ... print("KeyError raised") + KeyError raised + """ + for hook in registry(): + if hook.name == name: + return hook + raise KeyError(name) diff --git a/tests/experimental/agents/hooks/test_registry.py b/tests/experimental/agents/hooks/test_registry.py new file mode 100644 index 000000000..52e9de379 --- /dev/null +++ b/tests/experimental/agents/hooks/test_registry.py @@ -0,0 +1,26 @@ +"""Tests for the hook registry + canonical event map.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.agents.hooks.base import EVENT_STATE +from libtmux.experimental.agents.hooks.registry import get, registry + + +def test_event_state_map_is_canonical() -> None: + """EVENT_STATE maps the four canonical lifecycle events to state strings.""" + assert EVENT_STATE["turn_start"] == "running" + assert EVENT_STATE["needs_approval"] == "awaiting_input" + + +def test_registry_has_claude_and_codex() -> None: + """registry() returns at least one hook for claude and one for codex.""" + names = {hook.name for hook in registry()} + assert {"claude", "codex"} <= names + + +def test_get_unknown_raises() -> None: + """get() raises KeyError when the requested hook name is not registered.""" + with pytest.raises(KeyError): + get("nope") From daba041ef8a419c4cde86188f4ade48857d71944 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:39:00 -0500 Subject: [PATCH 175/223] Agents(feat[hooks]): Add Claude Code hook installer (non-clobbering) --- .../experimental/agents/hooks/claude.py | 346 ++++++++++++++++-- .../experimental/agents/hooks/test_claude.py | 70 ++++ 2 files changed, 386 insertions(+), 30 deletions(-) create mode 100644 tests/experimental/agents/hooks/test_claude.py diff --git a/src/libtmux/experimental/agents/hooks/claude.py b/src/libtmux/experimental/agents/hooks/claude.py index 9ea1a9f30..051a1bbf1 100644 --- a/src/libtmux/experimental/agents/hooks/claude.py +++ b/src/libtmux/experimental/agents/hooks/claude.py @@ -1,71 +1,357 @@ -"""Stub for ClaudeCodeHook — completed in Task 14. +"""Claude Code lifecycle-hook installer. -This module exists so that :mod:`libtmux.experimental.agents.hooks.registry` -can import :class:`ClaudeCodeHook` without error while Task 14 is not yet -merged. The real implementation (config-dir detection, hook-script writing, -status inspection) will replace this body in Task 14. +Installs and uninstalls agent-state-emitting hooks into Claude Code's +``~/.claude/settings.json``. Each installed hook runs +``libtmux-agent-emit `` on the relevant Claude lifecycle event. + +The ``"hooks"`` section of ``settings.json`` is keyed by Claude event name; +each event value is a list of *groups*, where each group is a dict:: + + {"hooks": [{"type": "command", "command": ""}]} + +Only groups whose command contains ``libtmux-agent-emit`` are ever touched; +all other user groups are preserved verbatim. """ from __future__ import annotations +import contextlib +import json +import os +import pathlib +import shutil +import tempfile +import typing as t + +#: Claude Code event name → agent-state string. +#: +#: Maps each Claude lifecycle event to the agent-state value that +#: ``libtmux-agent-emit`` should broadcast when that event fires. +#: +#: Examples +#: -------- +#: >>> _CLAUDE_EVENT_STATE["UserPromptSubmit"] +#: 'running' +#: >>> _CLAUDE_EVENT_STATE["Stop"] +#: 'awaiting_input' +_CLAUDE_EVENT_STATE: dict[str, str] = { + "UserPromptSubmit": "running", + "Notification": "awaiting_input", + "Stop": "awaiting_input", + "SessionStart": "idle", +} + +#: Substring present in every hook command we own; used to identify our entries. +_OUR_MARKER: str = "libtmux-agent-emit" + + +def _is_our_group(group: dict[str, t.Any]) -> bool: + """Return ``True`` when *group* contains at least one of our hook commands. + + Parameters + ---------- + group : dict[str, Any] + A hook group dict (``{"hooks": [{...}]}``) from ``settings.json``. + + Returns + ------- + bool + ``True`` iff any ``"command"`` value inside the group's ``"hooks"`` + list contains :data:`_OUR_MARKER`. + + Notes + ----- + The substring match here is intentionally *wider* than the exact-command + match :meth:`ClaudeCodeHook.status` uses: it claims any group emitted by + any version of this installer (e.g. with extra flags) so uninstall and + idempotent install always sweep them. The false-positive risk -- a user + command that literally contains ``libtmux-agent-emit`` -- is negligible. + + Examples + -------- + >>> _is_our_group({"hooks": [{"type": "command", + ... "command": "libtmux-agent-emit running"}]}) + True + >>> _is_our_group({"hooks": [{"type": "command", + ... "command": "echo user-owned"}]}) + False + >>> _is_our_group({"hooks": []}) + False + """ + return any(_OUR_MARKER in h.get("command", "") for h in group.get("hooks", [])) + + +def _our_group(state: str) -> dict[str, t.Any]: + """Return a fresh hook group that emits *state*. + + Parameters + ---------- + state : str + Agent-state string (e.g. ``"running"``). + + Returns + ------- + dict[str, Any] + A hook group dict ready to be inserted into a Claude event list. + + Examples + -------- + >>> g = _our_group("running") + >>> g["hooks"][0]["command"] + 'libtmux-agent-emit running' + >>> g["hooks"][0]["type"] + 'command' + """ + return {"hooks": [{"type": "command", "command": f"{_OUR_MARKER} {state}"}]} + class ClaudeCodeHook: """Lifecycle-hook installer for Claude Code. - .. note:: - This is a **stub** completed in Task 14. All methods are no-ops; - :meth:`detect` returns ``False`` and :meth:`status` returns - ``"absent"`` until the real implementation lands. + Merges ``libtmux-agent-emit`` hook entries into Claude Code's + ``settings.json`` without touching any existing user hooks. All + mutations are written atomically (temp file + ``os.replace``). + + Parameters + ---------- + settings_path : pathlib.Path or None + Path to ``settings.json``. Defaults to + ``~/.claude/settings.json`` when ``None``. Examples -------- - >>> ClaudeCodeHook().name + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... before = hook.status() + ... hook.install() + ... after = hook.status() + ... hook.uninstall() + ... gone = hook.status() + >>> hook.name 'claude' - >>> ClaudeCodeHook().detect() - False - >>> ClaudeCodeHook().status() - 'absent' + >>> (before, after, gone) + ('absent', 'installed', 'absent') """ #: Short machine identifier understood by the registry. name: str = "claude" + def __init__(self, settings_path: pathlib.Path | None = None) -> None: + self._settings_path: pathlib.Path = ( + settings_path + if settings_path is not None + else pathlib.Path.home() / ".claude" / "settings.json" + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _load(self) -> dict[str, t.Any]: + """Load and return the settings dict, or an empty dict if absent. + + Returns + ------- + dict[str, Any] + Parsed JSON content of the settings file, or ``{}`` when the + file does not exist. + + Notes + ----- + Only a missing file is treated as empty. A present-but-malformed + settings file surfaces :exc:`json.JSONDecodeError` unchanged: we fail + loud rather than silently overwrite a user's corrupt config. + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... sink = ClaudeCodeHook(settings_path=pathlib.Path(d) / "s.json") + ... loaded = sink._load() + >>> loaded + {} + """ + try: + with self._settings_path.open(encoding="utf-8") as fh: + return t.cast(dict[str, t.Any], json.load(fh)) + except FileNotFoundError: + return {} + + def _save(self, data: dict[str, t.Any]) -> None: + """Write *data* atomically to :attr:`_settings_path`. + + Parameters + ---------- + data : dict[str, Any] + JSON-serialisable settings dict. + + Notes + ----- + Uses a sibling temp file + ``os.fsync`` + ``os.replace`` so that + no partial write ever survives a crash. + + Examples + -------- + >>> import json, pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "s.json") + ... hook._save({"hooks": {}}) + ... result = json.loads((pathlib.Path(d) / "s.json").read_text()) + >>> result + {'hooks': {}} + """ + directory = self._settings_path.parent + directory.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(directory), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.flush() + os.fsync(fh.fileno()) + pathlib.Path(tmp).replace(self._settings_path) + except BaseException: + with contextlib.suppress(OSError): + pathlib.Path(tmp).unlink() + raise + + # ------------------------------------------------------------------ + # Public interface (AgentHook protocol) + # ------------------------------------------------------------------ + def detect(self) -> bool: - """Return ``False`` — stub; real detection added in Task 14. + """Return ``True`` when the Claude Code config dir and binary are present. + + Returns + ------- + bool + ``True`` when both ``~/.claude/`` (or the parent of + *settings_path*) exists **and** a ``claude`` binary is on + ``$PATH``; ``False`` otherwise. Examples -------- - >>> ClaudeCodeHook().detect() - False + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... ok = isinstance(hook.detect(), bool) + >>> ok + True """ - # TODO(Task 14): detect ~/.claude or `claude` binary presence - return False + return ( + self._settings_path.parent.exists() and shutil.which("claude") is not None + ) def install(self) -> None: - """No-op — stub; real installer added in Task 14. + """Merge our hook entries into the settings file (idempotent). + + For each Claude lifecycle event in :data:`_CLAUDE_EVENT_STATE`: + + 1. Load the current settings (or start from ``{}`` if absent). + 2. Strip any existing groups that contain ``libtmux-agent-emit`` + (idempotency — removes a previous install before re-adding). + 3. Append a fresh group with the correct ``libtmux-agent-emit`` + command. + 4. Write the result atomically. + + User-owned groups (those whose commands do **not** contain + ``libtmux-agent-emit``) are never modified. Examples -------- - >>> ClaudeCodeHook().install() + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... hook.install() + ... status = hook.status() + >>> status + 'installed' """ - # TODO(Task 14): write hook scripts into ~/.claude/hooks/ + data = self._load() + hooks_section: dict[str, list[dict[str, t.Any]]] = data.get("hooks", {}) + for event, state in _CLAUDE_EVENT_STATE.items(): + groups: list[dict[str, t.Any]] = list(hooks_section.get(event, [])) + # Remove any previous our-entries (idempotency). + groups = [g for g in groups if not _is_our_group(g)] + # Append a fresh our-entry. + groups.append(_our_group(state)) + hooks_section[event] = groups + data["hooks"] = hooks_section + self._save(data) def uninstall(self) -> None: - """No-op — stub; real uninstaller added in Task 14. + """Remove only our hook entries; leave all user entries intact. + + For each event key in the settings ``"hooks"`` section, groups + whose command contains ``libtmux-agent-emit`` are removed. Any + event whose group list becomes empty is pruned from the section, so + an install-then-uninstall on a fresh file leaves ``"hooks"`` empty + rather than littered with empty arrays. Events that still hold + user-owned groups are preserved. The file is written atomically. Examples -------- - >>> ClaudeCodeHook().uninstall() + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "s.json") + ... hook.install() + ... hook.uninstall() + ... status = hook.status() + >>> status + 'absent' """ - # TODO(Task 14): remove hook scripts from ~/.claude/hooks/ + data = self._load() + hooks_section: dict[str, list[dict[str, t.Any]]] = data.get("hooks", {}) + for event in list(hooks_section): + hooks_section[event] = [ + g for g in hooks_section[event] if not _is_our_group(g) + ] + # Prune now-empty event keys; events with surviving user groups stay. + hooks_section = {k: v for k, v in hooks_section.items() if v} + data["hooks"] = hooks_section + self._save(data) def status(self) -> str: - """Return ``"absent"`` — stub; real status check added in Task 14. + """Return the installation status of our hooks. + + Reads the settings file and counts how many of the expected + ``libtmux-agent-emit`` commands are present. + + Returns + ------- + str + ``"installed"`` — all expected hooks present and matching. + ``"absent"`` — none of our hooks found. + ``"outdated"`` — some but not all hooks found. Examples -------- - >>> ClaudeCodeHook().status() - 'absent' + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... before = hook.status() + ... hook.install() + ... after = hook.status() + >>> (before, after) + ('absent', 'installed') """ - # TODO(Task 14): inspect hook scripts and return installed/outdated/absent - return "absent" + data = self._load() + hooks_section: dict[str, list[dict[str, t.Any]]] = data.get("hooks", {}) + + present = 0 + for event, state in _CLAUDE_EVENT_STATE.items(): + expected_cmd = f"{_OUR_MARKER} {state}" + groups = hooks_section.get(event, []) + found = any( + any(h.get("command") == expected_cmd for h in g.get("hooks", [])) + for g in groups + ) + if found: + present += 1 + + total = len(_CLAUDE_EVENT_STATE) + if present == total: + return "installed" + if present == 0: + return "absent" + return "outdated" diff --git a/tests/experimental/agents/hooks/test_claude.py b/tests/experimental/agents/hooks/test_claude.py new file mode 100644 index 000000000..25baf7cc2 --- /dev/null +++ b/tests/experimental/agents/hooks/test_claude.py @@ -0,0 +1,70 @@ +"""Tests for the Claude Code hook installer.""" + +from __future__ import annotations + +import json +import pathlib + +from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + + +def test_install_status_uninstall_roundtrip(tmp_path: pathlib.Path) -> None: + """Round-trip: absent → install → installed (non-clobber) → uninstall → absent.""" + settings = tmp_path / "settings.json" + settings.write_text( + json.dumps( + { + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "echo user-owned"}]} + ] + } + } + ) + ) + hook = ClaudeCodeHook(settings_path=settings) + + assert hook.status() == "absent" + hook.install() + assert hook.status() == "installed" + + data = json.loads(settings.read_text()) + stop_cmds = [h["command"] for grp in data["hooks"]["Stop"] for h in grp["hooks"]] + assert any("libtmux-agent-emit awaiting_input" in c for c in stop_cmds) + assert "echo user-owned" in stop_cmds # never clobber the user's hook + + hook.uninstall() + assert hook.status() == "absent" + data = json.loads(settings.read_text()) + stop_cmds = [ + h["command"] for grp in data["hooks"].get("Stop", []) for h in grp["hooks"] + ] + assert "echo user-owned" in stop_cmds # still there + + # Events we installed with no surviving user group are pruned entirely + # (not left as empty arrays); Stop survives via the user's group. + assert "UserPromptSubmit" not in data["hooks"] + assert "Notification" not in data["hooks"] + assert "SessionStart" not in data["hooks"] + assert "Stop" in data["hooks"] + + +def test_install_is_idempotent(tmp_path: pathlib.Path) -> None: + """Installing twice leaves exactly one copy of each our-entry per event.""" + settings = tmp_path / "settings.json" + hook = ClaudeCodeHook(settings_path=settings) + hook.install() + hook.install() + assert hook.status() == "installed" + + # Confirm no duplicate our-entries exist + data = json.loads(settings.read_text()) + for event in ("UserPromptSubmit", "Notification", "Stop", "SessionStart"): + groups = data["hooks"].get(event, []) + our_cmds = [ + h["command"] + for g in groups + for h in g.get("hooks", []) + if "libtmux-agent-emit" in h.get("command", "") + ] + assert len(our_cmds) == 1, f"duplicate our-entries under {event}: {our_cmds}" From 87179725e637d72cdcfc93a148cf48698feeb484 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:46:34 -0500 Subject: [PATCH 176/223] Agents(feat[hooks]): Add Codex hook installer --- .../experimental/agents/hooks/codex.py | 341 ++++++++++++++++-- tests/experimental/agents/hooks/test_codex.py | 50 +++ 2 files changed, 359 insertions(+), 32 deletions(-) create mode 100644 tests/experimental/agents/hooks/test_codex.py diff --git a/src/libtmux/experimental/agents/hooks/codex.py b/src/libtmux/experimental/agents/hooks/codex.py index 3b4c85ce0..95d9a4ef3 100644 --- a/src/libtmux/experimental/agents/hooks/codex.py +++ b/src/libtmux/experimental/agents/hooks/codex.py @@ -1,71 +1,348 @@ -"""Stub for CodexHook — completed in Task 15. +"""Codex CLI lifecycle-hook installer. -This module exists so that :mod:`libtmux.experimental.agents.hooks.registry` -can import :class:`CodexHook` without error while Task 15 is not yet merged. -The real implementation (config-dir detection, hook-script writing, status -inspection) will replace this body in Task 15. +Installs agent-state-emitting command hooks into Codex's ``config.toml`` +under the ``[hooks]`` section. Writes hooks between paired marker comments +so that :meth:`CodexHook.install`, :meth:`CodexHook.status`, and +:meth:`CodexHook.uninstall` operate only on our bounded block and preserve +the rest of ``config.toml`` verbatim. + +The modern ``[hooks]`` TOML format (array-of-tables ``[[hooks.]]``) is +the primary mechanism. Codex's older single-program ``notify`` hook (fires +on turn-complete only, emitting ``awaiting_input``) is a fallback for old +Codex versions — it is **not** implemented in v1; modern ``[hooks]`` is +primary. + +Codex event → state mapping:: + + user_prompt_submit → running + permission_request → awaiting_input + stop → awaiting_input + session_start → idle + +Each hook fires on a named Codex lifecycle event. Codex passes event JSON +on stdin, but each event registers a separate hook so the command hard-codes +its state. + +Marker-bounded write strategy +------------------------------ +Our TOML is appended (or replaced in-place) between two TOML comment +markers:: + + # >>> libtmux-agent-state >>> + ... our hook entries ... + # <<< libtmux-agent-state <<< + +All content outside the block is preserved byte-for-byte; the file is never +round-tripped through a TOML serialiser. ``status()`` and ``uninstall()`` +operate exclusively on the marker-bounded block. """ from __future__ import annotations +import contextlib +import os +import pathlib +import re +import shutil +import tempfile + +#: Codex event name → agent-state string. +#: +#: Maps each Codex lifecycle event to the agent-state value that +#: ``libtmux-agent-emit`` should broadcast when that event fires. +#: +#: Examples +#: -------- +#: >>> _CODEX_EVENT_STATE["user_prompt_submit"] +#: 'running' +#: >>> _CODEX_EVENT_STATE["session_start"] +#: 'idle' +_CODEX_EVENT_STATE: dict[str, str] = { + "user_prompt_submit": "running", + "permission_request": "awaiting_input", + "stop": "awaiting_input", + "session_start": "idle", +} + +_MARKER_START: str = "# >>> libtmux-agent-state >>>" +_MARKER_END: str = "# <<< libtmux-agent-state <<<" + +#: Matches the marker-bounded block body (no preceding separator newline). +_BLOCK_RE: re.Pattern[str] = re.compile( + re.escape(_MARKER_START) + r".*?" + re.escape(_MARKER_END) + r"\n?", + re.DOTALL, +) + +#: Matches the block body together with its preceding separator newline. +_BLOCK_WITH_SEP_RE: re.Pattern[str] = re.compile( + r"\n" + re.escape(_MARKER_START) + r".*?" + re.escape(_MARKER_END) + r"\n?", + re.DOTALL, +) + class CodexHook: - """Lifecycle-hook installer for OpenAI Codex CLI. + """Lifecycle-hook installer for the Codex CLI (OpenAI). - .. note:: - This is a **stub** completed in Task 15. All methods are no-ops; - :meth:`detect` returns ``False`` and :meth:`status` returns - ``"absent"`` until the real implementation lands. + Merges ``libtmux-agent-emit`` hook entries into Codex's + ``~/.codex/config.toml`` using a marker-bounded text block. + Content outside the block is preserved byte-for-byte. + + The TOML shape used for each event is an array-of-tables entry:: + + [[hooks.]] + type = "command" + command = "libtmux-agent-emit " + + This produces syntactically valid TOML (the whole file parses cleanly + with :mod:`tomllib` after install). + + Parameters + ---------- + config_path : pathlib.Path or None + Path to Codex's ``config.toml``. Defaults to + ``~/.codex/config.toml`` when ``None``. + + Notes + ----- + **Legacy notify fallback (not implemented in v1).** + Old Codex versions support a single-program ``notify`` hook that fires + on turn-complete only (equivalent to ``awaiting_input``). Modern + ``[hooks]`` is the primary mechanism; the ``notify`` path is documented + here for future reference but is not implemented. Examples -------- - >>> CodexHook().name - 'codex' - >>> CodexHook().detect() - False - >>> CodexHook().status() - 'absent' + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... before = hook.status() + ... hook.install() + ... after = hook.status() + ... hook.uninstall() + ... gone = hook.status() + >>> (before, after, gone) + ('absent', 'installed', 'absent') """ #: Short machine identifier understood by the registry. name: str = "codex" + def __init__(self, config_path: pathlib.Path | None = None) -> None: + self._config_path: pathlib.Path = ( + config_path + if config_path is not None + else pathlib.Path.home() / ".codex" / "config.toml" + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _read(self) -> str: + """Return the current file text, or ``""`` if the file is absent. + + Returns + ------- + str + File content decoded as UTF-8, or an empty string when the file + does not exist. + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... text = hook._read() + >>> text + '' + """ + try: + return self._config_path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + def _write(self, content: str) -> None: + r"""Write *content* atomically to :attr:`_config_path`. + + Creates parent directories as needed. Uses a sibling temp file plus + ``os.fsync`` + ``os.replace`` so no partial write survives a crash. + + Parameters + ---------- + content : str + Full file text to write. + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... hook._write('model = "o4"\n') + ... text = hook._read() + >>> text + 'model = "o4"\n' + """ + self._config_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp( + dir=str(self._config_path.parent), + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + pathlib.Path(tmp_path).replace(self._config_path) + except BaseException: + with contextlib.suppress(OSError): + pathlib.Path(tmp_path).unlink() + raise + + def _build_block(self) -> str: + r"""Build the marker-bounded TOML block string. + + Returns the block from start marker to end marker (inclusive) with a + trailing newline. The block does **not** include the leading separator + newline added by :meth:`install` when appending to a non-empty file. + + Returns + ------- + str + Ready-to-write block text ending with ``\n``. + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... block = hook._build_block() + >>> block.startswith("# >>> libtmux-agent-state >>>") + True + >>> "libtmux-agent-emit running" in block + True + >>> block.endswith("# <<< libtmux-agent-state <<<\n") + True + """ + lines = [_MARKER_START] + for event, state in _CODEX_EVENT_STATE.items(): + lines.append("") + lines.append(f"[[hooks.{event}]]") + lines.append('type = "command"') + lines.append(f'command = "libtmux-agent-emit {state}"') + lines.append(_MARKER_END) + return "\n".join(lines) + "\n" + + # ------------------------------------------------------------------ + # Public interface (AgentHook protocol) + # ------------------------------------------------------------------ + def detect(self) -> bool: - """Return ``False`` — stub; real detection added in Task 15. + """Return ``True`` when the Codex config dir and binary are present. + + Returns + ------- + bool + ``True`` when both the parent directory of *config_path* exists + **and** a ``codex`` binary is on ``$PATH``; ``False`` otherwise. Examples -------- - >>> CodexHook().detect() - False + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... result = hook.detect() + >>> isinstance(result, bool) + True """ - # TODO(Task 15): detect ~/.codex or `codex` binary presence - return False + return self._config_path.parent.exists() and shutil.which("codex") is not None def install(self) -> None: - """No-op — stub; real installer added in Task 15. + """Write our hook block into ``config.toml`` (idempotent). + + If our marker block is already present it is replaced in-place; + otherwise the block is appended with a blank-line separator. + Content outside the marker block is preserved verbatim. Examples -------- - >>> CodexHook().install() + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... hook.install() + ... status = hook.status() + >>> status + 'installed' """ - # TODO(Task 15): write hook scripts into ~/.codex/hooks/ + content = self._read() + new_block = self._build_block() + if _MARKER_START in content: + content = _BLOCK_RE.sub(new_block, content) + elif content: + if not content.endswith("\n"): + content += "\n" + content = content + "\n" + new_block + else: + content = new_block + self._write(content) def uninstall(self) -> None: - """No-op — stub; real uninstaller added in Task 15. + """Remove our marker-bounded block; leave everything else intact. + + Handles a missing file or absent block gracefully (no-op). The + separator newline added by :meth:`install` is also removed so that + the original file content is restored exactly. Examples -------- - >>> CodexHook().uninstall() + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... hook.install() + ... hook.uninstall() + ... status = hook.status() + >>> status + 'absent' """ - # TODO(Task 15): remove hook scripts from ~/.codex/hooks/ + content = self._read() + if _MARKER_START not in content: + return + new_content = _BLOCK_WITH_SEP_RE.sub("", content) + if new_content == content: + # Block was at the start of the file — no preceding newline. + new_content = _BLOCK_RE.sub("", content) + self._write(new_content) def status(self) -> str: - """Return ``"absent"`` — stub; real status check added in Task 15. + """Return the installation status of our hook block. + + Reads ``config.toml`` and checks whether our marker-bounded block is + present and matches what :meth:`install` would write today. + + Returns + ------- + str + ``"installed"`` — block present and content matches current + expected output. + ``"absent"`` — our start marker not found in the file. + ``"outdated"`` — start marker found but block content differs. Examples -------- - >>> CodexHook().status() - 'absent' + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... before = hook.status() + ... hook.install() + ... after = hook.status() + >>> (before, after) + ('absent', 'installed') """ - # TODO(Task 15): inspect hook scripts and return installed/outdated/absent - return "absent" + content = self._read() + if _MARKER_START not in content: + return "absent" + match = _BLOCK_RE.search(content) + if match is None: + return "absent" + if match.group(0) == self._build_block(): + return "installed" + return "outdated" diff --git a/tests/experimental/agents/hooks/test_codex.py b/tests/experimental/agents/hooks/test_codex.py new file mode 100644 index 000000000..c8e61acd2 --- /dev/null +++ b/tests/experimental/agents/hooks/test_codex.py @@ -0,0 +1,50 @@ +"""Tests for the Codex hook installer.""" + +from __future__ import annotations + +import pathlib +import sys + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib # type: ignore[import-not-found] + +from libtmux.experimental.agents.hooks.codex import CodexHook + + +def test_install_writes_event_hooks(tmp_path: pathlib.Path) -> None: + """Round-trip: absent → install → installed (non-clobber) → uninstall → absent.""" + config = tmp_path / "config.toml" + config.write_text('model = "o4"\n') # pre-existing unrelated config + hook = CodexHook(config_path=config) + + assert hook.status() == "absent" + hook.install() + assert hook.status() == "installed" + + text = config.read_text() + # All four Codex events and their states are present. + assert "user_prompt_submit" in text + assert "libtmux-agent-emit running" in text + assert "permission_request" in text + assert "libtmux-agent-emit awaiting_input" in text + assert "stop" in text + assert "session_start" in text + assert "libtmux-agent-emit idle" in text + assert 'model = "o4"' in text # untouched + + # File must parse as valid TOML after install + parsed = tomllib.loads(text) + assert isinstance(parsed, dict) + assert parsed["model"] == "o4" + + # Installing again exercises the in-place-replace branch: still installed, + # and the marker block appears exactly once (no duplication). + hook.install() + assert hook.status() == "installed" + assert config.read_text().count("# >>> libtmux-agent-state >>>") == 1 + + hook.uninstall() + assert hook.status() == "absent" + assert 'model = "o4"' in config.read_text() From d789e5afe0c2b600f490b3c58d97a5ecef9594ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:52:02 -0500 Subject: [PATCH 177/223] Mcp(feat[agents]): Add list_agents/watch_agents/install_agent_hooks tools --- src/libtmux/experimental/mcp/_lifespan.py | 25 +- .../experimental/mcp/fastmcp_adapter.py | 17 +- .../experimental/mcp/vocabulary/agents.py | 266 ++++++++++++++++++ tests/experimental/mcp/test_agents_tools.py | 26 ++ 4 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 src/libtmux/experimental/mcp/vocabulary/agents.py create mode 100644 tests/experimental/mcp/test_agents_tools.py diff --git a/src/libtmux/experimental/mcp/_lifespan.py b/src/libtmux/experimental/mcp/_lifespan.py index 2008fa06e..2161111a0 100644 --- a/src/libtmux/experimental/mcp/_lifespan.py +++ b/src/libtmux/experimental/mcp/_lifespan.py @@ -3,9 +3,12 @@ A startup preflight that fails fast if the engine cannot reach tmux at all (missing binary, a fundamentally broken connection) -- distinct from a tmux-side error such as "no server running", which the engine returns as data, not an -exception. Shutdown is a best-effort no-op: engine-ops does not namespace -MCP-created paste buffers, so there is no buffer GC to run (a documented -follow-up). +exception. When a streaming engine is in use, the optional +:class:`~libtmux.experimental.agents.monitor.AgentMonitor` is started after a +successful preflight and stopped on shutdown, so its drain loop runs for the +full lifetime of the server. Otherwise shutdown is a best-effort no-op: +engine-ops does not namespace MCP-created paste buffers, so there is no buffer +GC to run (a documented follow-up). """ from __future__ import annotations @@ -20,11 +23,13 @@ from fastmcp import FastMCP + from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.engines.base import AsyncTmuxEngine def make_lifespan( engine: AsyncTmuxEngine, + monitor: AgentMonitor | None = None, ) -> Callable[[FastMCP], contextlib.AbstractAsyncContextManager[None]]: """Return a FastMCP lifespan that probes *engine* at startup. @@ -32,6 +37,12 @@ def make_lifespan( only when the engine itself is broken (it raises -- missing binary, lost connection), never on a tmux-side failure, which comes back as a :class:`~..engines.base.CommandResult`. + + When *monitor* is provided, its + :meth:`~libtmux.experimental.agents.monitor.AgentMonitor.start` is awaited + after a successful preflight and + :meth:`~libtmux.experimental.agents.monitor.AgentMonitor.stop` on shutdown, + so the agent drain loop runs for the server's whole lifetime. """ @contextlib.asynccontextmanager @@ -41,6 +52,12 @@ async def _lifespan(_app: FastMCP) -> AsyncIterator[None]: except Exception as error: msg = f"tmux engine preflight failed: {error}" raise RuntimeError(msg) from error - yield + if monitor is not None: + await monitor.start() + try: + yield + finally: + if monitor is not None: + await monitor.stop() return _lifespan diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 1eac7b46c..a13068c0e 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -725,16 +725,27 @@ def build_async_server( from libtmux.experimental.mcp._lifespan import make_lifespan from libtmux.experimental.mcp.events import _supports_stream, register_events + from libtmux.experimental.mcp.vocabulary.agents import supports_monitor level = _resolve_level(safety_level) ctx = caller if caller is not None else CallerContext.discover() _stash_caller(engine, ctx) events_enabled = events != "off" and _supports_stream(engine) + # Build the agent monitor up front so the lifespan can start/stop it for the + # server's whole lifetime. The monitor needs the full control surface + # (subscribe + add_subscription + set_attach_targets), a stricter bar than + # the event tools' subscribe-only requirement. + monitor_enabled = supports_monitor(engine) + agent_monitor = None + if monitor_enabled: + from libtmux.experimental.agents.monitor import AgentMonitor + + agent_monitor = AgentMonitor(engine) mcp: FastMCP = FastMCP( name=name, instructions=instructions or _instructions(ctx, events_enabled=events_enabled), middleware=_make_middleware(level) if include_middleware else None, - lifespan=make_lifespan(engine) if lifespan else None, + lifespan=make_lifespan(engine, agent_monitor) if lifespan else None, ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) @@ -758,5 +769,9 @@ def build_async_server( register_resources(mcp, engine, is_async=True) register_events(mcp, engine, mode=events, source=event_source) + if monitor_enabled: + from libtmux.experimental.mcp.vocabulary.agents import register_agents + + register_agents(mcp, engine, monitor=agent_monitor) _apply_safety_gate(mcp, level) return mcp diff --git a/src/libtmux/experimental/mcp/vocabulary/agents.py b/src/libtmux/experimental/mcp/vocabulary/agents.py new file mode 100644 index 000000000..43bce28b7 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/agents.py @@ -0,0 +1,266 @@ +"""MCP tool surface for the AgentMonitor -- list, watch, and hook installation. + +Three tools are registered on the FastMCP server by :func:`register_agents`: + +- ``list_agents`` -- a snapshot of all currently tracked agents; no tmux + round-trip, reads straight from the in-process store. +- ``watch_agents`` -- bounded stream collection: drains + :meth:`~libtmux.experimental.agents.monitor.AgentMonitor.ingest` for up to + *timeout_s* seconds and returns the state transitions observed. +- ``install_agent_hooks`` -- calls the named hook's + :meth:`~libtmux.experimental.agents.hooks.base.AgentHook.install` and + returns the hook's updated status. + +This module mirrors the registration style of +:mod:`libtmux.experimental.mcp.events` (``FunctionTool.from_function`` + +``mcp.add_tool``) so the lifecycle is identical: tools are registered at +server-build time; the monitor is *started* by the caller (the lifespan) and +*stopped* on shutdown. No unmanaged background task is spawned at import or +registration time. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import typing as t + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + from libtmux.experimental.agents.monitor import AgentMonitor + from libtmux.experimental.agents.store import Storage + +# The methods AgentMonitor.start() drives on the engine. A streaming engine that +# only exposes subscribe() (enough for the event tools) is not enough for the +# monitor, whose start() also installs a subscription and sets attach targets. +_MONITOR_ENGINE_METHODS = ("subscribe", "add_subscription", "set_attach_targets") + + +def supports_monitor(engine: t.Any) -> bool: + """Whether *engine* exposes the full control surface the monitor needs. + + The :class:`~libtmux.experimental.agents.monitor.AgentMonitor` drives more + than ``subscribe()``: its :meth:`start` calls ``add_subscription`` and + ``set_attach_targets``. Gating on this (rather than only ``subscribe``) + avoids starting the monitor against a stream-only engine and crashing the + lifespan with ``AttributeError``. + + Examples + -------- + >>> class _Stream: + ... async def subscribe(self): ... + >>> supports_monitor(_Stream()) + False + >>> class _Full: + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> supports_monitor(_Full()) + True + """ + return all( + callable(getattr(engine, method, None)) for method in _MONITOR_ENGINE_METHODS + ) + + +def register_agents( + mcp: FastMCP, + engine: t.Any, + *, + sink: Storage | None = None, + monitor: AgentMonitor | None = None, +) -> AgentMonitor: + """Register ``list_agents``, ``watch_agents``, and ``install_agent_hooks`` on *mcp*. + + Registers the three MCP tools that expose an + :class:`~libtmux.experimental.agents.monitor.AgentMonitor`, and returns the + monitor so the caller (the server lifespan) can drive its + :meth:`~libtmux.experimental.agents.monitor.AgentMonitor.start` / + :meth:`~libtmux.experimental.agents.monitor.AgentMonitor.stop`. When + *monitor* is ``None`` a fresh one is constructed (using *sink*); pass an + existing instance to share the same monitor with the lifespan. + + Parameters + ---------- + mcp : FastMCP + The FastMCP server instance on which to register the tools. + engine : object + An async tmux engine with ``run``, ``subscribe``, ``add_subscription``, + and ``set_attach_targets`` methods. + sink : Storage or None + Optional persistence sink forwarded to a freshly constructed monitor + (ignored when *monitor* is provided). + monitor : AgentMonitor or None + An existing monitor to expose; when ``None`` one is constructed. + + Returns + ------- + AgentMonitor + The monitor backing the tools (not started by this call). + + Examples + -------- + >>> class _FakeMcp: + ... def add_tool(self, tool): ... + >>> class _FakeEngine: + ... async def run(self, req): ... + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> from libtmux.experimental.mcp.vocabulary.agents import register_agents + >>> mon = register_agents(_FakeMcp(), _FakeEngine()) + >>> mon.status() + {'agents': 0, 'generation': 0} + """ + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + from libtmux.experimental.agents.hooks.registry import get + from libtmux.experimental.agents.monitor import AgentMonitor + + if monitor is None: + monitor = AgentMonitor(engine, sink=sink) + + # ------------------------------------------------------------------ + # list_agents + # ------------------------------------------------------------------ + + async def list_agents() -> list[dict[str, t.Any]]: + """Return a snapshot of all currently tracked agents. + + Reads directly from the in-process agent store -- no tmux round-trip. + Each entry has ``pane_id``, ``name``, ``state`` (string value), + ``since`` (monotonic timestamp), ``alive``, and ``source``. + + Returns + ------- + list[dict[str, Any]] + One dict per tracked agent pane. + """ + return [ + { + "pane_id": a.pane_id, + "name": a.name, + "state": a.state.value, + "since": a.since, + "alive": a.alive, + "source": a.source, + } + for a in monitor.agents + ] + + mcp.add_tool( + FunctionTool.from_function( + list_agents, + name="list_agents", + description="Snapshot of all currently tracked coding-agent panes", + tags={"readonly", "agents"}, + annotations=ToolAnnotations(title="list_agents", readOnlyHint=True), + ), + ) + + # ------------------------------------------------------------------ + # watch_agents + # ------------------------------------------------------------------ + + async def watch_agents(timeout_s: float = 5.0) -> dict[str, t.Any]: + """Collect agent-state transitions for up to *timeout_s* seconds. + + Drains the engine's ``subscribe()`` stream through + :meth:`~libtmux.experimental.agents.monitor.AgentMonitor.ingest` + and returns any state changes observed within the window. + + Parameters + ---------- + timeout_s : float + Wall-clock seconds to collect before returning (default 5.0). + + Returns + ------- + dict[str, Any] + ``transitions`` -- list of ``{"pane_id", "before", "after"}`` dicts + for agents whose state changed; ``count`` -- number of transitions. + """ + snapshot_before = {a.pane_id: a.state.value for a in monitor.agents} + + async def _collect() -> None: + async with contextlib.aclosing(engine.subscribe()) as stream: + async for note in stream: + monitor.ingest(note.raw) + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(_collect(), timeout=timeout_s) + + snapshot_after = {a.pane_id: a.state.value for a in monitor.agents} + transitions = [ + { + "pane_id": pid, + "before": snapshot_before.get(pid), + "after": state, + } + for pid, state in snapshot_after.items() + if snapshot_before.get(pid) != state + ] + return {"transitions": transitions, "count": len(transitions)} + + mcp.add_tool( + FunctionTool.from_function( + watch_agents, + name="watch_agents", + description=( + "Collect agent-state transitions from the live tmux stream " + "for up to timeout_s seconds" + ), + tags={"readonly", "agents"}, + annotations=ToolAnnotations(title="watch_agents", readOnlyHint=True), + ), + ) + + # ------------------------------------------------------------------ + # install_agent_hooks + # ------------------------------------------------------------------ + + async def install_agent_hooks(agent: str) -> dict[str, t.Any]: + """Install lifecycle hooks for the named coding agent. + + Calls :func:`~libtmux.experimental.agents.hooks.registry.get` to look + up the hook installer, runs + :meth:`~libtmux.experimental.agents.hooks.base.AgentHook.install`, + then returns the hook's updated + :meth:`~libtmux.experimental.agents.hooks.base.AgentHook.status`. + + Parameters + ---------- + agent : str + Hook name, e.g. ``"claude"`` or ``"codex"``. + + Returns + ------- + dict[str, Any] + ``{"agent": name, "status": "installed"|"outdated"|"absent"}`` + on success, or ``{"agent": name, "error": "unknown agent"}`` when + *agent* is not registered. + """ + try: + hook = get(agent) + except KeyError: + return {"agent": agent, "error": "unknown agent"} + hook.install() + return {"agent": agent, "status": hook.status()} + + mcp.add_tool( + FunctionTool.from_function( + install_agent_hooks, + name="install_agent_hooks", + description=( + "Install lifecycle hooks for a named coding agent (claude/codex)" + ), + tags={"mutating", "agents"}, + annotations=ToolAnnotations( + title="install_agent_hooks", readOnlyHint=False + ), + ), + ) + + return monitor diff --git a/tests/experimental/mcp/test_agents_tools.py b/tests/experimental/mcp/test_agents_tools.py new file mode 100644 index 000000000..93c480b17 --- /dev/null +++ b/tests/experimental/mcp/test_agents_tools.py @@ -0,0 +1,26 @@ +"""Tests for the agents MCP tools (callables driven directly).""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.agents.monitor import AgentMonitor + + +class _FakeEngine: + async def run(self, request: object) -> None: ... + + async def subscribe(self) -> t.AsyncIterator[object]: + return + yield + + def add_subscription(self, spec: object) -> None: ... + def set_attach_targets(self, ids: object) -> None: ... + + +def test_list_agents_reflects_ingested_state() -> None: + """list_agents shape: ingested option-line produces the expected pane dict.""" + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + listing = [{"pane_id": a.pane_id, "state": a.state.value} for a in mon.agents] + assert {"pane_id": "%1", "state": "running"} in listing From 9510d3e881a00e0effa484c62b70db9116261008 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:58:32 -0500 Subject: [PATCH 178/223] Agents(feat): Monitor self-attach + live tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Per-pane %subscription-changed only flows to an *attached* control client, but AgentMonitor.start() never attached — so the monitor was silent against a live server. The spec's "re-attach declared sessions" step was deferred in Tasks 9/10 and never wired. This closes that gap and proves the whole pipeline end to end. what: - monitor.start(): after add_subscription, pick a real session via list-sessions, set_attach_targets([id]), and perform the initial attach-session through the engine (set _attached_session). A tmux -C client creates its own throwaway session on connect, which sorts first in list-sessions; _own_session_id (display-message -p '#{session_id}') identifies it so _primary_session_id skips it and attaches to a real session. Single-session v1 limit documented. - async_control_mode: add _replay_attach(), called after _replay_subscriptions on every (re)connect — mirrors the subscription replay (direct stdin write, swallowed pending future, _write_lock/FIFO discipline) so the engine re-attaches across reconnects. No-op when _desired_attach is empty. - tests/experimental/agents/test_live_monitor.py: live tests against real tmux. test_monitor_observes_running sets @agent_state running and polls (no manual attach — asserts start() self-attached). test_reconcile_parses_live_panes proves _parse_pane_rows handles real list-panes -F output and the Vanished->EXITED path. --- src/libtmux/experimental/agents/monitor.py | 116 ++++++++++++++++- .../experimental/agents/test_live_monitor.py | 117 ++++++++++++++++++ 2 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 tests/experimental/agents/test_live_monitor.py diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 351529cce..29670f4be 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -229,16 +229,122 @@ def status(self) -> dict[str, t.Any]: # ------------------------------------------------------------------ async def start(self) -> None: - """Install the subscription and begin draining the engine event stream. - - Spawns a background task that feeds every notification into - :meth:`ingest`. Also attempts an initial :meth:`reconcile` to - synchronise against the current pane tree. + """Install the subscription, attach a session, and drain the event stream. + + Three steps: + + 1. Install the ``@agent_state`` subscription on the engine. + 2. Attach the engine to a session. tmux only delivers per-pane + ``%subscription-changed`` notifications to an *attached* control + client, so without this the option channel is silent against a live + server. A control client attaches to one session at a time; v1 + attaches to the first session reported by ``list-sessions`` and + records it via :meth:`set_attach_targets` so the engine re-attaches + across reconnects. The ``%*`` subscription still installs across all + panes, but per-pane option signals for *other* sessions' panes need + their own attached client — a known v1 limitation. + 3. Spawn the drain task feeding every notification into :meth:`ingest`, + then run an initial :meth:`reconcile` to sync the pane tree. + + All attach steps are defensive: a failed ``list-sessions`` or + ``attach-session`` is logged and skipped so :meth:`start` never crashes. """ + from libtmux.experimental.engines.base import CommandRequest + self._engine.add_subscription(SUBSCRIPTION) + + # Attach a session so per-pane %subscription-changed actually flows. + session_id = await self._primary_session_id() + if session_id is not None: + self._engine.set_attach_targets([session_id]) + try: + await self._engine.run( + CommandRequest.from_args("attach-session", "-t", session_id) + ) + # Mirror the events layer: record the sticky attach so a later + # _ensure_attached (MCP) does not redundantly re-attach. + self._engine._attached_session = session_id + logger.debug("monitor attached session %s", session_id) + except Exception: + logger.debug("monitor attach-session failed", exc_info=True) + self._task = asyncio.get_running_loop().create_task(self._drain()) await self.reconcile() + async def _primary_session_id(self) -> str | None: + """Return the first *real* session id to attach to, or ``None``. + + A ``tmux -C`` control client creates its own throwaway session on + connect (``tmux -C`` with no command implies ``new-session``); that + phantom holds no agent panes and per-pane notifications for it are + useless. So this skips the control client's own session (identified via + ``display-message -p '#{session_id}'``) and returns the first remaining + session — tmux orders ``list-sessions`` by name, so this is the + alphabetically-first real session. Falls back to the client's own + session only when no other exists (an otherwise empty server). + + Defensive: any engine error (no daemon, list failure) is logged and + yields ``None`` so :meth:`start` can proceed without attaching. + + Returns + ------- + str | None + The session id to attach to, or ``None`` when the list is empty or + the engine call failed. + """ + from libtmux.experimental.engines.base import CommandRequest + + own = await self._own_session_id() + try: + result = await self._engine.run( + CommandRequest.from_args("list-sessions", "-F", "#{session_id}") + ) + except Exception: + logger.debug( + "list-sessions failed — monitor will not attach", exc_info=True + ) + return None + ids: list[str] = [ + str(line).strip() for line in result.stdout if str(line).strip() + ] + for sid in ids: + if sid != own: + return sid + # Only the control client's own session exists: nothing real to watch, + # but attaching to it is harmless (and keeps the option channel live). + return ids[0] if ids else None + + async def _own_session_id(self) -> str | None: + """Return the control client's own session id, or ``None``. + + Right after the engine connects, ``tmux -C`` is attached to the + throwaway session it created; ``display-message -p '#{session_id}'`` + (no target → the client's current session) reports it. Used by + :meth:`_primary_session_id` to avoid attaching the monitor to its own + phantom session. Defensive: any engine error yields ``None``. + + Returns + ------- + str | None + The control client's current ``#{session_id}``, or ``None`` if the + engine call failed or returned nothing. + """ + from libtmux.experimental.engines.base import CommandRequest + + try: + result = await self._engine.run( + CommandRequest.from_args("display-message", "-p", "#{session_id}") + ) + except Exception: + logger.debug( + "display-message failed — cannot detect own session", exc_info=True + ) + return None + for line in result.stdout: + if str(line).strip(): + return str(line).strip() + return None + async def stop(self) -> None: """Cancel the drain task and optionally flush the sink.""" if self._task is not None: diff --git a/tests/experimental/agents/test_live_monitor.py b/tests/experimental/agents/test_live_monitor.py new file mode 100644 index 000000000..66c1852cb --- /dev/null +++ b/tests/experimental/agents/test_live_monitor.py @@ -0,0 +1,117 @@ +"""Live: a real @agent_state write becomes observable through the monitor.""" + +from __future__ import annotations + +import asyncio +import typing as t + +if t.TYPE_CHECKING: + from libtmux.session import Session + +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +def test_monitor_observes_running(session: Session) -> None: + """@agent_state option set on a real pane is observed within 3s. + + Starts the monitor — which self-attaches a session, the prerequisite for + tmux to deliver per-pane ``%subscription-changed`` (without an attached + control client only server-global events arrive) — then writes + ``@agent_state running`` to the active pane's option (simulating what an + agent hook would do) and polls up to 3s for the monitor to surface the + update. No manual ``attach-session`` here: this proves + :meth:`AgentMonitor.start` attaches on its own. Exercises the full + subscribe→ingest→store pipeline against a real tmux server. + """ + + async def main() -> str: + engine = AsyncControlModeEngine.for_server(session.server) + monitor = AgentMonitor(engine) + await monitor.start() + # start() must have self-attached a session for the option channel. + assert engine._attached_session is not None + active_pane = session.active_window.active_pane + assert active_pane is not None + pane_id = active_pane.pane_id + assert pane_id is not None + # the agent hook's effect, simulated: + session.cmd("set-option", "-p", "-t", pane_id, "@agent_state", "running") + # tmux's subscription timer is ~1 s; poll up to 3 s + match = None + for _ in range(30): + await asyncio.sleep(0.1) + match = {a.pane_id: a for a in monitor.agents}.get(pane_id) + if match is not None and match.state.value == "running": + break + await monitor.stop() + return match.state.value if match else "missing" + + assert asyncio.run(main()) == "running" + + +def test_reconcile_parses_live_panes(session: Session) -> None: + """reconcile() parses real list-panes -F output and tracks pane lifecycle. + + Verifies three things: + + 1. ``_parse_pane_rows`` actually produces rows from real ``list-panes`` + output (not silently empty due to a field-separator or column-order + mismatch). + 2. A newly-created pane appears in the monitor's internal pane map after + a second reconcile. + 3. A killed pane that was previously observed (state in the store) is + marked ``EXITED`` after the next reconcile — proving the ``Vanished`` + path works end to end. + """ + + async def main() -> None: + async with AsyncControlModeEngine.for_server(session.server) as engine: + monitor = AgentMonitor(engine) + + # First reconcile: seeds _prev_panes from live tmux output. + await monitor.reconcile() + + # Prove _parse_pane_rows returned real rows (field sep / column order OK). + assert len(monitor._prev_panes) > 0, ( + "_parse_pane_rows returned empty — field separator mismatch " + "or list-panes produced no rows" + ) + + # Create a fresh window (and its default first pane). + new_window = session.new_window(window_name="reconcile-test") + new_pane = new_window.active_pane + assert new_pane is not None + new_pane_id = new_pane.pane_id + assert new_pane_id is not None + + # Manually observe the new pane so it lands in the store — Vanished + # only transitions panes that are already tracked. + monitor.ingest( + f"%subscription-changed agentstate $0 @0 1 {new_pane_id} : running" + ) + by_pane = {a.pane_id: a for a in monitor.agents} + assert by_pane[new_pane_id].state is AgentState.RUNNING + + # Reconcile again: new pane should appear in _prev_panes. + await monitor.reconcile() + assert new_pane_id in monitor._prev_panes, ( + "new pane not detected by reconcile after creation — " + "_parse_pane_rows may be silently skipping rows" + ) + + # Kill the pane, then reconcile. The pane was tracked, so the + # Vanished event should transition it to EXITED in the store. + session.cmd("kill-pane", "-t", new_pane_id) + await monitor.reconcile() + + by_pane = {a.pane_id: a for a in monitor.agents} + assert new_pane_id in by_pane, ( + "pane disappeared from store entirely — expected EXITED entry" + ) + assert by_pane[new_pane_id].state is AgentState.EXITED, ( + f"expected EXITED after kill-pane, got {by_pane[new_pane_id].state}" + ) + + asyncio.run(main()) From 46c5a938a58538de2bfbdc36cebb2ee2cc4b4ab6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:14:26 -0500 Subject: [PATCH 179/223] Agents(docs): Document the agent-state monitor + CHANGES entry why: Complete the feature branch with user-facing documentation and a changelog entry so the agent-state monitor is discoverable. what: - Add superpowers/** to exclude_patterns in docs/conf.py so the spec and plan are tracked in git without generating toctree warnings - Add ## Agents section to docs/experimental.md with prose intro and MyST-role cross-refs for AgentMonitor, AgentState, Agent, and the three MCP tools - Add Agent-state monitor deliverable to CHANGES under ### What's new in prose (not bullets), linking {class} roles for AgentMonitor, AgentState, and AgentHook - Stage docs/superpowers/ plans and specs into version control --- CHANGES | 22 + docs/conf.py | 5 + docs/experimental.md | 44 + .../2026-06-26-agents-agent-state-monitor.md | 1939 +++++++++++++++++ ...06-26-agents-agent-state-monitor-design.md | 263 +++ 5 files changed, 2273 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-26-agents-agent-state-monitor.md create mode 100644 docs/superpowers/specs/2026-06-26-agents-agent-state-monitor-design.md diff --git a/CHANGES b/CHANGES index 969f1486d..17885ff61 100644 --- a/CHANGES +++ b/CHANGES @@ -47,6 +47,28 @@ _Notes on the upcoming release will go here._ ### What's new +#### Agent-state monitor + +libtmux 0.59.x ships `libtmux.experimental.agents`, a live agent-state monitor +that lets you see, at a glance, which coding agent in which pane is running, +waiting for your input, or idle — all without polling or scraping pane output. +A {class}`~libtmux.experimental.agents.monitor.AgentMonitor` subscribes to a +control-mode engine and coalesces incoming tmux notifications into per-pane +{class}`~libtmux.experimental.agents.state.Agent` records carrying the agent's +name, its current {class}`~libtmux.experimental.agents.state.AgentState` +(`RUNNING`, `AWAITING_INPUT`, `IDLE`, `EXITED`, or `UNKNOWN`), the timestamp of +the last transition, and a liveness flag refreshed by a periodic health probe. + +Agents publish their state through tmux option subscriptions or OSC escape +sequences; when both signals arrive for the same pane the monitor applies a +last-writer-wins merge so the in-memory store stays consistent without locks. +A reconciliation sweep runs every few seconds to catch any pane the stream +missed, compare it against the stored snapshot, and emit the minimal diff — +so the monitor self-heals across reconnects and supervisor restarts. Shell +hooks for Claude Code and Codex are included; install them into a running +session at any time via `install_agent_hooks` or the bundled +{class}`~libtmux.experimental.agents.hooks.base.AgentHook` installer API. + #### Experimental operations and engines (#690) Operations describe tmux commands as data. Each renders its argv against a tmux diff --git a/docs/conf.py b/docs/conf.py index 5efc0c9a9..079ad47f2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -59,3 +59,8 @@ exclude_patterns=["_build", "AGENTS.md", "CLAUDE.md"], ) globals().update(conf) + +# Exclude design specs and implementation plans from the Sphinx build so that +# docs/superpowers/** files are tracked in git without generating toctree +# warnings. +exclude_patterns += ["superpowers/**"] # type: ignore[name-defined] # noqa: F821 diff --git a/docs/experimental.md b/docs/experimental.md index d3b112412..83a814d4d 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -173,3 +173,47 @@ the code. ```{tmuxop-catalog} :safety: destructive ``` + +## Agents + +```{warning} +The agent-state monitor is experimental and subject to change without notice. +``` + +The `libtmux.experimental.agents` package gives you a live, server-side view +of every coding agent running across your tmux sessions. A +{class}`~libtmux.experimental.agents.monitor.AgentMonitor` subscribes to a +control-mode engine, classifies incoming tmux notifications, and coalesces them +into a per-pane {class}`~libtmux.experimental.agents.state.Agent` record — +carrying the agent's name, its current {class}`~libtmux.experimental.agents.state.AgentState` +(`RUNNING`, `AWAITING_INPUT`, `IDLE`, `EXITED`, or `UNKNOWN`), the timestamp of +the last transition, and a liveness flag updated by a periodic health probe. + +Agents report their state via tmux option subscriptions or OSC escape sequences. +When both signals arrive for the same pane the monitor applies a +last-writer-wins merge so the store stays consistent without locks. A full-pane +reconciliation sweep runs every few seconds to catch any pane the stream missed, +compare it against the stored snapshot, and emit the minimal diff — so the +monitor self-heals across reconnects and supervisor restarts. + +### Installing agent hooks + +Before a coding agent can report state, its shell hooks must be installed into +the tmux session. {class}`~libtmux.experimental.agents.hooks.base.AgentHook` +subclasses (`ClaudeCodeHook`, `CodexHook`) write the necessary `after-*` hooks +into the running session via a tmux `set-hook` call. The MCP tool +`install_agent_hooks` does this on demand — pass `"claude"` or `"codex"` as the +agent name and the tool installs the hooks in the monitor's target session. + +### MCP tools + +When `libtmux-mcp` is running with the agent monitor wired in, three tools are +exposed to LLM clients: + +- **`list_agents`** — returns a snapshot of every currently tracked agent: + pane id, name, state string, seconds since last transition, and liveness. +- **`watch_agents`** — collects state-change events for a bounded window (default + 5 s) and returns them as a list, useful for agents that need to wait for a + peer to reach `AWAITING_INPUT` before sending a message. +- **`install_agent_hooks`** — installs the named agent's shell hooks into the + session so the monitor can begin receiving state signals. diff --git a/docs/superpowers/plans/2026-06-26-agents-agent-state-monitor.md b/docs/superpowers/plans/2026-06-26-agents-agent-state-monitor.md new file mode 100644 index 000000000..9b784dfe1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-agents-agent-state-monitor.md @@ -0,0 +1,1939 @@ +# Agents — Agent-State Monitor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `libtmux.experimental.agents` — a headless, resilient monitor that reports each tmux pane's coding-agent state (`RUNNING`/`AWAITING_INPUT`/`IDLE`/`EXITED`/`UNKNOWN`), fed by cooperative agent hooks (Claude Code + Codex), observed over the async control-mode engine, and exposed via FastMCP tools. + +**Architecture:** tmux is the authority for the observed tree (the monitor is a projection); the monitor is the authority for agent state. State flows one direction: `subscribe() → classify → latest()-merge → store → derived tree → MCP`. A supervisor loop makes the control connection self-healing (reconnect → re-attach → resubscribe → reconcile). Per-pane agent state is a latest-wins entry (`Stamp(counter, writer)`), transport-shaped for a future multi-host pivot but single-host in v1. + +**Tech Stack:** Python 3.10+, asyncio, the existing `experimental/engines` (AsyncControlModeEngine), `experimental/ops`, `experimental/models/snapshots.py`, `experimental/mcp` (FastMCP). No new third-party dependencies. + +## Global Constraints + +- `from __future__ import annotations` at the top of every module. +- Namespace stdlib imports: `import enum`, `import dataclasses`, `import typing as t`, `import os`, `import json`, `import asyncio`. `from dataclasses import dataclass, field` is the one allowed `from` for stdlib. +- NumPy-style docstrings on every public function/method/class; a **working doctest** on every public symbol (no `# doctest: +SKIP`). +- Functional tests only (no `class TestFoo`). Reuse fixtures `server`, `session`, `window`, `pane`. No `pytest-asyncio` — async tests wrap an inner `async def main()` driven by `asyncio.run`. +- Lazy `%`-style logging via `logging.getLogger(__name__)`; never f-strings in log calls. +- New package lives under `src/libtmux/experimental/agents/`; tests under `tests/experimental/agents/`. +- Naming is fixed: module/object names are `AgentState`, `Agent`, `Stamp`, `latest`, `AgentStore`, `apply`, `Storage`, `JsonFile`, `AgentMonitor`, `OptionSignal`, `OscSignal`, `AgentHook`, `ClaudeCodeHook`, `CodexHook`. Do not rename. +- Pre-commit gate (run before each commit): `uv run ruff format .` → `uv run ruff check . --fix` → `uv run mypy src tests` → `uv run pytest`. +- The design spec (`docs/superpowers/specs/2026-06-26-agents-agent-state-monitor-design.md`) must be excluded from the Sphinx build (Task 16) so `just build-docs` stays clean. + +--- + +### Task 1: `AgentState` enum + `Agent` record + +**Files:** +- Create: `src/libtmux/experimental/agents/__init__.py` +- Create: `src/libtmux/experimental/agents/state.py` +- Test: `tests/experimental/agents/__init__.py`, `tests/experimental/agents/test_state.py` + +**Interfaces:** +- Produces: `AgentState` (str enum: `RUNNING`, `AWAITING_INPUT`, `IDLE`, `EXITED`, `UNKNOWN`); `Agent` frozen dataclass with fields `pane_id: str`, `key: str`, `name: str | None`, `state: AgentState`, `since: float`, `source: str`, `pid: int | None`, `alive: bool`, and properties `is_awaiting`/`is_running`; `AgentState.from_signal(value: str) -> AgentState` (maps a hook's raw string, unknown → `UNKNOWN`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/test_state.py +"""Tests for the AgentState enum and Agent record.""" + +from __future__ import annotations + +from libtmux.experimental.agents.state import Agent, AgentState + + +def test_from_signal_maps_known_and_unknown() -> None: + assert AgentState.from_signal("running") is AgentState.RUNNING + assert AgentState.from_signal("awaiting_input") is AgentState.AWAITING_INPUT + assert AgentState.from_signal("idle") is AgentState.IDLE + assert AgentState.from_signal("garbage") is AgentState.UNKNOWN + + +def test_agent_helpers() -> None: + agent = Agent( + pane_id="%1", key="%1", name="claude", state=AgentState.AWAITING_INPUT, + since=1.0, source="option", pid=42, alive=True, + ) + assert agent.is_awaiting is True + assert agent.is_running is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/test_state.py -v` +Expected: FAIL — `ModuleNotFoundError: libtmux.experimental.agents.state`. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/libtmux/experimental/agents/__init__.py +"""Agent-state monitoring over tmux (experimental).""" + +from __future__ import annotations +``` + +```python +# src/libtmux/experimental/agents/state.py +"""The agent-state vocabulary: the AgentState enum and the Agent record.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass + + +class AgentState(str, enum.Enum): + """What a coding agent in a pane is doing. + + Examples + -------- + >>> AgentState.from_signal("running") + + >>> AgentState.from_signal("nonsense") + + """ + + RUNNING = "running" + AWAITING_INPUT = "awaiting_input" + IDLE = "idle" + EXITED = "exited" + UNKNOWN = "unknown" + + @classmethod + def from_signal(cls, value: str) -> AgentState: + """Map a hook's raw state string to an :class:`AgentState`. + + Unrecognized values become :attr:`UNKNOWN` rather than raising, so a + malformed signal can never crash the monitor. + """ + try: + return cls(value.strip().lower()) + except ValueError: + return cls.UNKNOWN + + +@dataclass(frozen=True) +class Agent: + """A pane's coding agent and its current state. + + Examples + -------- + >>> a = Agent(pane_id="%1", key="%1", name="claude", + ... state=AgentState.RUNNING, since=1.0, source="option", + ... pid=42, alive=True) + >>> a.is_running, a.is_awaiting + (True, False) + """ + + pane_id: str + key: str + name: str | None + state: AgentState + since: float + source: str + pid: int | None + alive: bool + + @property + def is_awaiting(self) -> bool: + """True when the agent is paused waiting on the human/orchestrator.""" + return self.state is AgentState.AWAITING_INPUT + + @property + def is_running(self) -> bool: + """True when the agent is actively working.""" + return self.state is AgentState.RUNNING +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/test_state.py -v` +Expected: PASS (2 tests). Also `uv run pytest --doctest-modules src/libtmux/experimental/agents/state.py`. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/__init__.py src/libtmux/experimental/agents/state.py tests/experimental/agents/ +git commit -m "$(cat <<'EOF' +Agents(feat[state]): Add AgentState enum + Agent record + +why: The shared vocabulary every agents module reads/writes. + +what: +- AgentState (running/awaiting_input/idle/exited/unknown) with from_signal +- frozen Agent record + is_running/is_awaiting helpers +EOF +)" +``` + +--- + +### Task 2: `merge.py` — latest-wins ordering (`Stamp` + `latest`) + +**Files:** +- Create: `src/libtmux/experimental/agents/merge.py` +- Test: `tests/experimental/agents/test_merge.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Stamp` frozen dataclass `(counter: int, writer: str)` with total ordering; `latest(current: Stamp | None, incoming: Stamp) -> bool`; `Clock = t.Callable[[], int]`; `MonotonicCounter` callable (a stateful `__call__() -> int` that strictly increments) usable as a default `Clock`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/test_merge.py +"""Tests for latest-wins ordering.""" + +from __future__ import annotations + +from libtmux.experimental.agents.merge import MonotonicCounter, Stamp, latest + + +def test_latest_prefers_higher_counter() -> None: + assert latest(Stamp(1, "option"), Stamp(2, "option")) is True + assert latest(Stamp(2, "option"), Stamp(1, "option")) is False + + +def test_latest_tie_breaks_on_writer() -> None: + # equal counters: deterministic tie-break, never a coin flip + assert latest(Stamp(1, "option"), Stamp(1, "osc")) is True + assert latest(Stamp(1, "osc"), Stamp(1, "option")) is False + + +def test_latest_accepts_first_value() -> None: + assert latest(None, Stamp(0, "option")) is True + + +def test_monotonic_counter_strictly_increases() -> None: + clock = MonotonicCounter() + assert [clock(), clock(), clock()] == [1, 2, 3] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/test_merge.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/libtmux/experimental/agents/merge.py +"""Latest-wins ordering: when two updates for one pane race, keep the newer. + +A ``Stamp`` is a logical clock ``(counter, writer)``. ``latest`` decides whether +an incoming stamp should replace the current one. The clock is pluggable: a +monotonic counter is single-host-correct; an HLC can drop in later for multi-host +without touching call sites. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +Clock = t.Callable[[], int] + + +@dataclass(frozen=True, order=True) +class Stamp: + """A logical-clock tag on one state update. + + Ordered by ``counter`` first, then ``writer`` (a deterministic tie-break when + two sources stamp the same counter). + + Examples + -------- + >>> Stamp(2, "option") > Stamp(1, "osc") + True + >>> Stamp(1, "osc") > Stamp(1, "option") + True + """ + + counter: int + writer: str + + +def latest(current: Stamp | None, incoming: Stamp) -> bool: + """Return ``True`` if *incoming* should replace *current* (it is newer). + + Examples + -------- + >>> latest(None, Stamp(0, "option")) + True + >>> latest(Stamp(5, "option"), Stamp(4, "option")) + False + """ + return current is None or incoming > current + + +class MonotonicCounter: + """A strictly-increasing integer clock for single-host stamping. + + Examples + -------- + >>> clock = MonotonicCounter() + >>> clock(), clock() + (1, 2) + """ + + def __init__(self) -> None: + self._value = 0 + + def __call__(self) -> int: + """Return the next integer (strictly greater than the previous).""" + self._value += 1 + return self._value +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/test_merge.py --doctest-modules src/libtmux/experimental/agents/merge.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/merge.py tests/experimental/agents/test_merge.py +git commit -m "$(cat <<'EOF' +Agents(feat[merge]): Add latest-wins Stamp ordering + +why: Out-of-order/replayed agent-state updates must converge to newest. + +what: +- Stamp(counter, writer) with deterministic tie-break +- latest() guard + pluggable Clock + MonotonicCounter default +EOF +)" +``` + +--- + +### Task 3: `store.py` — `AgentStore`, `apply` reducer, `Storage` + `JsonFile` + +**Files:** +- Create: `src/libtmux/experimental/agents/store.py` +- Test: `tests/experimental/agents/test_store.py` + +**Interfaces:** +- Consumes: `Agent`, `AgentState` (Task 1); `Stamp`, `latest` (Task 2). +- Produces: + - `Observed` frozen dataclass `(pane_id, key, name, state: AgentState, stamp: Stamp, source: str, pid: int | None)` — a state event. + - `Vanished` frozen dataclass `(pane_id)` — a pane is gone. + - `AgentStore` frozen dataclass with `agents: dict[str, Agent]` and `stamps: dict[str, Stamp]`, plus `to_dict()`/`from_dict()`. + - `apply(store: AgentStore, event: Observed | Vanished, *, now: float) -> AgentStore` — pure; applies the `latest()` guard for `Observed`, marks `EXITED` for `Vanished`. + - `Storage` protocol (`load() -> dict | None`, `save(data: dict) -> None`). + - `JsonFile(path)` — atomic `Storage` (temp file + `os.replace` + `fsync`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/test_store.py +"""Tests for the durable store + reducer.""" + +from __future__ import annotations + +import json + +from libtmux.experimental.agents.merge import Stamp +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.store import ( + AgentStore, + JsonFile, + Observed, + Vanished, + apply, +) + + +def _observed(state: str, counter: int) -> Observed: + return Observed( + pane_id="%1", key="%1", name="claude", + state=AgentState.from_signal(state), stamp=Stamp(counter, "option"), + source="option", pid=42, + ) + + +def test_apply_keeps_latest_and_ignores_stale() -> None: + store = AgentStore() + store = apply(store, _observed("running", 2), now=10.0) + # a stale (lower-counter) update must not clobber the fresher one + store = apply(store, _observed("idle", 1), now=11.0) + assert store.agents["%1"].state is AgentState.RUNNING + + +def test_apply_advances_on_newer() -> None: + store = AgentStore() + store = apply(store, _observed("running", 1), now=10.0) + store = apply(store, _observed("awaiting_input", 2), now=11.0) + assert store.agents["%1"].state is AgentState.AWAITING_INPUT + + +def test_vanished_marks_exited() -> None: + store = AgentStore() + store = apply(store, _observed("running", 1), now=10.0) + store = apply(store, Vanished(pane_id="%1"), now=12.0) + assert store.agents["%1"].state is AgentState.EXITED + assert store.agents["%1"].alive is False + + +def test_jsonfile_atomic_roundtrip(tmp_path) -> None: + store = AgentStore() + store = apply(store, _observed("running", 1), now=10.0) + sink = JsonFile(tmp_path / "agents.json") + sink.save(store.to_dict()) + # a partial temp file must never be left behind + assert not list(tmp_path.glob("*.tmp")) + restored = AgentStore.from_dict(sink.load()) + assert restored.agents["%1"].state is AgentState.RUNNING + # the saved file is valid JSON + assert json.loads((tmp_path / "agents.json").read_text())["agents"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/test_store.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/libtmux/experimental/agents/store.py +"""The durable agent-state store and its pure reducer. + +The store maps each pane to its current :class:`Agent` and the :class:`Stamp` +that produced it. The only mutator is :func:`apply`, a pure reducer that applies +the latest-wins guard. ``Storage``/``JsonFile`` persist the store atomically; +persistence is an optional seed in v1 (agents re-announce on reconnect). +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import tempfile +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.agents.merge import Stamp, latest +from libtmux.experimental.agents.state import Agent, AgentState + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Mapping + + +@dataclass(frozen=True) +class Observed: + """An observed agent-state update from a signal source.""" + + pane_id: str + key: str + name: str | None + state: AgentState + stamp: Stamp + source: str + pid: int | None + + +@dataclass(frozen=True) +class Vanished: + """A pane that no longer exists (from reconcile or the health sweep).""" + + pane_id: str + + +@dataclass(frozen=True) +class AgentStore: + """The current agent per pane plus the stamp that produced it. + + Examples + -------- + >>> AgentStore().agents + {} + """ + + agents: dict[str, Agent] = field(default_factory=dict) + stamps: dict[str, Stamp] = field(default_factory=dict) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to plain JSON-able data.""" + return { + "agents": { + key: { + "pane_id": a.pane_id, "key": a.key, "name": a.name, + "state": a.state.value, "since": a.since, + "source": a.source, "pid": a.pid, "alive": a.alive, + } + for key, a in self.agents.items() + }, + "stamps": { + key: [s.counter, s.writer] for key, s in self.stamps.items() + }, + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> AgentStore: + """Reconstruct from :meth:`to_dict` output.""" + agents = { + key: Agent( + pane_id=a["pane_id"], key=a["key"], name=a["name"], + state=AgentState(a["state"]), since=a["since"], + source=a["source"], pid=a["pid"], alive=a["alive"], + ) + for key, a in data.get("agents", {}).items() + } + stamps = { + key: Stamp(counter=v[0], writer=v[1]) + for key, v in data.get("stamps", {}).items() + } + return cls(agents=agents, stamps=stamps) + + +def apply( + store: AgentStore, event: Observed | Vanished, *, now: float +) -> AgentStore: + """Return a new store with *event* applied (pure; latest-wins for Observed). + + Examples + -------- + >>> from libtmux.experimental.agents.merge import Stamp + >>> s = apply(AgentStore(), Observed("%1", "%1", "c", AgentState.RUNNING, + ... Stamp(1, "option"), "option", 7), now=1.0) + >>> s.agents["%1"].state + + """ + agents = dict(store.agents) + stamps = dict(store.stamps) + if isinstance(event, Vanished): + prev = agents.get(event.pane_id) + if prev is not None: + agents[event.pane_id] = dataclasses.replace( + prev, state=AgentState.EXITED, alive=False, since=now + ) + return AgentStore(agents=agents, stamps=stamps) + if not latest(stamps.get(event.pane_id), event.stamp): + return store + stamps[event.pane_id] = event.stamp + agents[event.pane_id] = Agent( + pane_id=event.pane_id, key=event.key, name=event.name, + state=event.state, since=now, source=event.source, + pid=event.pid, alive=True, + ) + return AgentStore(agents=agents, stamps=stamps) + + +@t.runtime_checkable +class Storage(t.Protocol): + """A persistence sink for the store.""" + + def load(self) -> dict[str, t.Any] | None: + """Return the persisted dict, or ``None`` if absent.""" + ... + + def save(self, data: dict[str, t.Any]) -> None: + """Persist *data* durably.""" + ... + + +class JsonFile: + """An atomic JSON :class:`Storage` (temp file + ``os.replace`` + ``fsync``). + + Examples + -------- + >>> import tempfile, pathlib + >>> d = pathlib.Path(tempfile.mkdtemp()) + >>> sink = JsonFile(d / "x.json") + >>> sink.save({"agents": {}, "stamps": {}}) + >>> sink.load()["agents"] + {} + """ + + def __init__(self, path: str | pathlib.Path) -> None: + self._path = os.fspath(path) + + def load(self) -> dict[str, t.Any] | None: + """Return the persisted dict, or ``None`` if the file is absent.""" + try: + with open(self._path, encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError: + return None + + def save(self, data: dict[str, t.Any]) -> None: + """Write *data* atomically (no partial file ever survives a crash).""" + directory = os.path.dirname(self._path) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(data, handle) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, self._path) + except BaseException: + with __import__("contextlib").suppress(OSError): + os.unlink(tmp) + raise +``` + +> Note: replace the inline `__import__("contextlib")` with a top-level `import contextlib` and `contextlib.suppress(OSError)` — shown inline only to keep the snippet self-contained. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/test_store.py --doctest-modules src/libtmux/experimental/agents/store.py -v` +Expected: PASS (4 tests + doctests). + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/store.py tests/experimental/agents/test_store.py +git commit -m "Agents(feat[store]): Add AgentStore + pure apply reducer + atomic JsonFile" +``` + +--- + +### Task 4: `signals.py` — `OptionSignal` + `OscSignal` parsers + +**Files:** +- Create: `src/libtmux/experimental/agents/signals.py` +- Test: `tests/experimental/agents/test_signals.py` + +**Interfaces:** +- Consumes: `AgentState` (Task 1). +- Produces: + - `Reading` frozen dataclass `(pane_id: str, state: AgentState, name: str | None, source: str)`. + - `OptionSignal.parse(notification_raw: str) -> Reading | None` — parses a `%subscription-changed agentstate $S @W idx %P : VALUE` line; returns `None` for non-matching lines. The subscription spec constant `SUBSCRIPTION = "agentstate:%*:#{@agent_state}"`. + - `OscSignal` — a per-pane byte accumulator: `feed(pane_id: str, data: bytes) -> list[Reading]` scans for `OSC 3008 ;state=… ST` across fragmented chunks, returning a Reading per complete sequence; tolerant of partial sequences spanning calls. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/test_signals.py +"""Tests for the two agent-state signal parsers.""" + +from __future__ import annotations + +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.signals import OptionSignal, OscSignal + + +def test_option_signal_parses_subscription_changed() -> None: + line = "%subscription-changed agentstate $0 @0 1 %3 : running" + reading = OptionSignal.parse(line) + assert reading is not None + assert reading.pane_id == "%3" + assert reading.state is AgentState.RUNNING + assert reading.source == "option" + + +def test_option_signal_ignores_other_notifications() -> None: + assert OptionSignal.parse("%output %1 hello") is None + assert OptionSignal.parse("%window-add @3") is None + + +def test_osc_signal_reassembles_fragmented_bytes() -> None: + osc = OscSignal() + # the probe proved %output arrives byte-fragmented; feed one byte at a time + payload = b"\033]3008;state=awaiting_input\033\\" + readings: list = [] + for i in range(len(payload)): + readings.extend(osc.feed("%2", payload[i : i + 1])) + assert len(readings) == 1 + assert readings[0].pane_id == "%2" + assert readings[0].state is AgentState.AWAITING_INPUT + assert readings[0].source == "osc" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/test_signals.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/libtmux/experimental/agents/signals.py +"""The two channels an agent uses to report state. + +``OptionSignal`` reads tmux ``@agent_state`` user-options surfaced as +``%subscription-changed`` (local; ~1 s debounced, re-queryable). ``OscSignal`` +reads a bare ``OSC 3008`` escape out of ``%output`` (remote/SSH; instant), with a +per-pane accumulator because tmux delivers ``%output`` byte-fragmented. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from libtmux.experimental.agents.state import AgentState + +#: The ``refresh-client -B`` spec the monitor installs for the local channel. +SUBSCRIPTION = "agentstate:%*:#{@agent_state}" + +_SUB_RE = re.compile( + r"^%subscription-changed\s+agentstate\s+\S+\s+\S+\s+\S+\s+(?P%\d+)\s+:\s+(?P\S+)" +) +_OSC_RE = re.compile(rb"\033\]3008;([^\033\007]*)(?:\033\\|\007)") + + +@dataclass(frozen=True) +class Reading: + """One observed agent-state reading from a signal channel.""" + + pane_id: str + state: AgentState + name: str | None + source: str + + +def _parse_payload(payload: str) -> tuple[AgentState, str | None]: + """Parse an OSC/option payload like ``state=running`` (``name=`` optional).""" + state = AgentState.UNKNOWN + name: str | None = None + for part in payload.split(";"): + key, _, value = part.partition("=") + if key == "state": + state = AgentState.from_signal(value) + elif key == "name": + name = value or None + return state, name + + +class OptionSignal: + """Parse the local ``@agent_state`` subscription channel.""" + + @staticmethod + def parse(notification_raw: str) -> Reading | None: + """Parse a ``%subscription-changed`` line; ``None`` if it isn't one. + + Examples + -------- + >>> r = OptionSignal.parse( + ... "%subscription-changed agentstate $0 @0 1 %3 : running") + >>> r.pane_id, r.state.value + ('%3', 'running') + >>> OptionSignal.parse("%output %1 hi") is None + True + """ + match = _SUB_RE.match(notification_raw) + if match is None: + return None + state = AgentState.from_signal(match.group("value")) + return Reading(match.group("pane"), state, None, "option") + + +class OscSignal: + """Reassemble ``OSC 3008`` agent-state escapes out of fragmented ``%output``. + + Examples + -------- + >>> osc = OscSignal() + >>> osc.feed("%1", b"\\033]3008;state=idle\\033\\\\")[0].state.value + 'idle' + """ + + def __init__(self) -> None: + self._buffers: dict[str, bytes] = {} + + def feed(self, pane_id: str, data: bytes) -> list[Reading]: + """Append *data* for *pane_id*; return a Reading per complete escape.""" + buffer = self._buffers.get(pane_id, b"") + data + readings: list[Reading] = [] + while True: + match = _OSC_RE.search(buffer) + if match is None: + break + payload = match.group(1).decode(errors="replace") + state, name = _parse_payload(payload) + readings.append(Reading(pane_id, state, name, "osc")) + buffer = buffer[match.end() :] + # keep only a bounded tail so a never-terminated OSC can't grow unbounded + self._buffers[pane_id] = buffer[-4096:] + return readings +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/test_signals.py --doctest-modules src/libtmux/experimental/agents/signals.py -v` +Expected: PASS (3 tests + doctests). + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/signals.py tests/experimental/agents/test_signals.py +git commit -m "Agents(feat[signals]): Add OptionSignal + fragmented-OSC OscSignal parsers" +``` + +--- + +### Task 5: `health.py` — process-aliveness check + +**Files:** +- Create: `src/libtmux/experimental/agents/health.py` +- Test: `tests/experimental/agents/test_health.py` + +**Interfaces:** +- Produces: `is_alive(pid: int | None) -> bool` — `os.kill(pid, 0)` semantics (`True` if signalable, `False` on `ProcessLookupError`, `True` on `PermissionError` — exists but not ours; `None` → `True` (PID-less remote: never declared dead by this check)). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/test_health.py +"""Tests for process-aliveness.""" + +from __future__ import annotations + +import os + +from libtmux.experimental.agents.health import is_alive + + +def test_self_is_alive() -> None: + assert is_alive(os.getpid()) is True + + +def test_absent_pid_is_dead() -> None: + # PID 0x7FFFFFFF is almost certainly not a live process + assert is_alive(2_147_483_646) is False + + +def test_pidless_remote_never_declared_dead() -> None: + assert is_alive(None) is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/test_health.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/libtmux/experimental/agents/health.py +"""Is the process behind a pane still alive? + +Local panes carry a ``pane_pid`` we can probe with ``os.kill(pid, 0)``. Remote +(SSH) panes are PID-less; this check never declares them dead — they expire on a +keepalive TTL owned by the monitor instead. +""" + +from __future__ import annotations + +import os + + +def is_alive(pid: int | None) -> bool: + """Return whether *pid* is a live process (``None`` → always alive). + + Examples + -------- + >>> import os + >>> is_alive(os.getpid()) + True + >>> is_alive(None) + True + """ + if pid is None: + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True # exists, owned by someone else + return True +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/test_health.py --doctest-modules src/libtmux/experimental/agents/health.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/health.py tests/experimental/agents/test_health.py +git commit -m "Agents(feat[health]): Add is_alive process probe (PID-less remote safe)" +``` + +--- + +### Task 6: `tree.py` — live session→window→pane tree + +**Files:** +- Create: `src/libtmux/experimental/agents/tree.py` +- Test: `tests/experimental/agents/test_tree.py` + +**Interfaces:** +- Consumes: `ServerSnapshot.from_pane_rows` from `libtmux.experimental.models.snapshots`. +- Produces: + - `PANE_FORMAT: tuple[str, ...]` — the format fields to request: `("session_id","session_name","window_id","window_index","window_name","window_active","pane_id","pane_index","pane_active","pane_pid","pane_current_command","pane_title")`. + - `panes_of(snapshot) -> dict[str, PaneSnapshot]` — flatten a `ServerSnapshot` to `{pane_id: PaneSnapshot}`. + - `diff_panes(old: dict, new: dict) -> tuple[list[str], list[str]]` — returns `(added_pane_ids, removed_pane_ids)` for synthetic reconcile events. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/test_tree.py +"""Tests for the derived tmux tree helpers.""" + +from __future__ import annotations + +from libtmux.experimental.agents.tree import diff_panes, panes_of +from libtmux.experimental.models.snapshots import ServerSnapshot + + +def _snap(pane_ids: list[str]) -> ServerSnapshot: + rows = [ + {"session_id": "$0", "window_id": "@0", "window_index": "0", + "pane_id": pid, "pane_index": str(i)} + for i, pid in enumerate(pane_ids) + ] + return ServerSnapshot.from_pane_rows(rows) + + +def test_panes_of_flattens() -> None: + assert set(panes_of(_snap(["%1", "%2"]))) == {"%1", "%2"} + + +def test_diff_panes_reports_added_and_removed() -> None: + old = panes_of(_snap(["%1", "%2"])) + new = panes_of(_snap(["%2", "%3"])) + added, removed = diff_panes(old, new) + assert added == ["%3"] + assert removed == ["%1"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/test_tree.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/libtmux/experimental/agents/tree.py +"""The live session→window→pane tree, derived from tmux (never the truth). + +A thin layer over ``ServerSnapshot.from_pane_rows``: the format to request, a +flattener to a ``{pane_id: PaneSnapshot}`` map, and a diff used by the monitor's +reconcile to synthesize the add/remove events the notification stream missed. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from libtmux.experimental.models.snapshots import PaneSnapshot, ServerSnapshot + +PANE_FORMAT: tuple[str, ...] = ( + "session_id", "session_name", + "window_id", "window_index", "window_name", "window_active", + "pane_id", "pane_index", "pane_active", "pane_pid", + "pane_current_command", "pane_title", +) + + +def panes_of(snapshot: ServerSnapshot) -> dict[str, PaneSnapshot]: + """Flatten a server snapshot to ``{pane_id: PaneSnapshot}``. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import ServerSnapshot + >>> snap = ServerSnapshot.from_pane_rows( + ... [{"session_id": "$0", "window_id": "@0", "pane_id": "%1"}]) + >>> list(panes_of(snap)) + ['%1'] + """ + return { + pane.pane_id: pane + for session in snapshot.sessions + for window in session.windows + for pane in window.panes + } + + +def diff_panes( + old: dict[str, t.Any], new: dict[str, t.Any] +) -> tuple[list[str], list[str]]: + """Return ``(added_pane_ids, removed_pane_ids)`` between two pane maps. + + Examples + -------- + >>> diff_panes({"%1": 1, "%2": 1}, {"%2": 1, "%3": 1}) + (['%3'], ['%1']) + """ + added = [pid for pid in new if pid not in old] + removed = [pid for pid in old if pid not in new] + return added, removed +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/test_tree.py --doctest-modules src/libtmux/experimental/agents/tree.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/tree.py tests/experimental/agents/test_tree.py +git commit -m "Agents(feat[tree]): Add derived pane tree + reconcile diff helpers" +``` + +--- + +### Task 7: Extend the `refresh-client` op with `-B`/`-C` + +**Files:** +- Modify: `src/libtmux/experimental/ops/_ops/refresh_client.py` +- Test: `tests/experimental/ops/test_refresh_client_subscribe.py` + +**Interfaces:** +- Consumes/Produces: `RefreshClient` gains two optional fields — `subscribe: str | None = None` (emits `-B `) and `size: str | None = None` (emits `-C `). `args()` returns them in order: `-B` then `-C`. Existing behavior (empty args) unchanged when both are `None`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/ops/test_refresh_client_subscribe.py +"""Tests for refresh-client -B/-C support.""" + +from __future__ import annotations + +from libtmux.experimental.ops._ops.refresh_client import RefreshClient +from libtmux.experimental.ops._types import ClientName + + +def test_subscribe_emits_dash_b() -> None: + op = RefreshClient(target=ClientName("/dev/pts/3"), + subscribe="agentstate:%*:#{@agent_state}") + assert op.render() == ( + "refresh-client", "-t", "/dev/pts/3", + "-B", "agentstate:%*:#{@agent_state}", + ) + + +def test_size_emits_dash_c() -> None: + op = RefreshClient(target=ClientName("/dev/pts/3"), size="200x50") + assert op.render() == ("refresh-client", "-t", "/dev/pts/3", "-C", "200x50") + + +def test_no_extra_args_by_default() -> None: + op = RefreshClient(target=ClientName("/dev/pts/3")) + assert op.render() == ("refresh-client", "-t", "/dev/pts/3") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/ops/test_refresh_client_subscribe.py -v` +Expected: FAIL — `TypeError: unexpected keyword 'subscribe'`. + +- [ ] **Step 3: Write minimal implementation** + +Modify `src/libtmux/experimental/ops/_ops/refresh_client.py`: add the two fields and rewrite `args()`: + +```python + subscribe: str | None = None + size: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Emit ``-B `` and/or ``-C `` when set.""" + out: list[str] = [] + if self.subscribe is not None: + out += ["-B", self.subscribe] + if self.size is not None: + out += ["-C", self.size] + return tuple(out) +``` + +Add a doctest line to the class docstring demonstrating `subscribe=`. Confirm `render()` already prefixes `-t ` (it does, via `Operation.render`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/ops/test_refresh_client_subscribe.py --doctest-modules src/libtmux/experimental/ops/_ops/refresh_client.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/ops/_ops/refresh_client.py tests/experimental/ops/test_refresh_client_subscribe.py +git commit -m "Ops(feat[refresh_client]): Add typed -B subscription + -C size" +``` + +--- + +### Task 8: Engine death-sentinel — close subscriber generators on death + +**Files:** +- Modify: `src/libtmux/experimental/engines/async_control_mode.py` +- Test: `tests/experimental/engines/test_async_control_mode_sentinel.py` + +**Interfaces:** +- Consumes: existing `AsyncControlModeEngine` internals (`_subscribers`, `_mark_dead`, `subscribe`, `_reader`, `aclose` at lines 144–414). +- Produces: a private sentinel object `_STREAM_END`; `subscribe()` raises `StopAsyncIteration` (ends the `async for`) when it dequeues `_STREAM_END`; `_mark_dead` and `aclose` broadcast `_STREAM_END` to every subscriber queue (via the existing `_offer`/`put_nowait` path) so a dead stream **closes** consumers instead of hanging — making `accumulate_until_settle` return `reason="stream_end"` (it already does on `StopAsyncIteration`, `_settle.py:259`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/engines/test_async_control_mode_sentinel.py +"""A dead engine must CLOSE subscriber generators, not hang them.""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.engines.async_control_mode import ( + AsyncControlModeEngine, + ControlModeError, +) + + +def test_subscribe_ends_when_engine_marked_dead() -> None: + async def main() -> list: + engine = AsyncControlModeEngine() + # do not spawn tmux: drive _subscribers + _mark_dead directly + queue: asyncio.Queue = asyncio.Queue(maxsize=16) + engine._subscribers.add(queue) + + seen: list = [] + + async def consume() -> None: + agen = engine.subscribe() + # re-register the real subscribe queue, then mark dead + async for note in agen: + seen.append(note) + + task = asyncio.create_task(consume()) + await asyncio.sleep(0.05) + engine._mark_dead(ControlModeError("boom")) + await asyncio.wait_for(task, timeout=1.0) # must NOT hang + return seen + + asyncio.run(main()) +``` + +> The test asserts the consumer task completes (no `asyncio.TimeoutError`). The exact `seen` contents don't matter — only that the generator ends. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/engines/test_async_control_mode_sentinel.py -v` +Expected: FAIL — `asyncio.TimeoutError` (the generator hangs on `queue.get()`). + +- [ ] **Step 3: Write minimal implementation** + +In `async_control_mode.py`: + +1. Add a module-level sentinel after the imports: + +```python +_STREAM_END = object() # broadcast to subscriber queues to end their async for +``` + +2. In `subscribe()` (line ~277), end on the sentinel: + +```python + try: + while True: + item = await queue.get() + if item is _STREAM_END: + return + yield item + finally: + self._subscribers.discard(queue) +``` + +(Type the queue as `asyncio.Queue[t.Any]` so the sentinel is allowed.) + +3. Add a broadcast helper and call it from `_mark_dead` and `aclose`: + +```python + def _broadcast_stream_end(self) -> None: + """Push the stream-end sentinel to every subscriber, then clear them.""" + for queue in list(self._subscribers): + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(_STREAM_END) + self._subscribers.clear() +``` + +Call `self._broadcast_stream_end()` at the end of `_mark_dead` (after `_fail_pending`) and inside `aclose` (after cancelling the reader, before failing pending). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/engines/test_async_control_mode_sentinel.py tests/experimental/ -k "control_mode" -v` +Expected: PASS, and no regressions in existing control-mode tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/engines/async_control_mode.py tests/experimental/engines/test_async_control_mode_sentinel.py +git commit -m "$(cat <<'EOF' +Engines(fix[async_control_mode]): Close subscribers on engine death + +why: A dead stream left consumers hanging on queue.get(), so settle +reported a false 'settled' (success-shaped) instead of stream_end. + +what: +- broadcast a _STREAM_END sentinel to subscriber queues on death/close +- subscribe() ends its async for on the sentinel +EOF +)" +``` + +--- + +### Task 9: Engine supervisor — reconnect → re-attach → resubscribe → reconcile + +**Files:** +- Modify: `src/libtmux/experimental/engines/async_control_mode.py` +- Test: `tests/experimental/engines/test_async_control_mode_supervisor.py` + +**Interfaces:** +- Consumes: the death-sentinel (Task 8); `start`/`aclose`/`_reader`. +- Produces: + - Desired-state fields on the engine: `self._desired_subscriptions: list[str]` and `self._desired_attach: list[str]` (sessions to attach), plus `self._generation: int` (bumped each (re)connect). + - `add_subscription(spec: str)` / `set_attach_targets(session_ids: list[str])` — record desired state (idempotent; replayed on reconnect). + - A `_closing: bool` flag set by `aclose()` before teardown so an intentional close isn't retried. + - A supervised reconnect: when the reader returns on EOF (not `_closing`), spawn a fresh proc with jittered backoff, reset the parser + fail pending, clear the sticky attach (Task 10 wires `events._attached_session`), replay subscriptions, then continue reading. v1 keeps this minimal: the test exercises one reconnect cycle. + +> This task is the largest brownfield change. Implement it against the file read in context. The reconnect lives in a `_supervisor()` task that owns the proc lifecycle; `start()` launches `_supervisor()` instead of `_reader()` directly. Keep the existing FIFO/`_pending` correlation; reconnect is the only place permitted to `_fail_pending` + fresh-`ControlModeParser`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/engines/test_async_control_mode_supervisor.py +"""The engine reconnects and replays desired state after the proc dies.""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +def test_desired_subscriptions_recorded_idempotently() -> None: + engine = AsyncControlModeEngine() + engine.add_subscription("agentstate:%*:#{@agent_state}") + engine.add_subscription("agentstate:%*:#{@agent_state}") # idempotent + assert engine._desired_subscriptions == ["agentstate:%*:#{@agent_state}"] + + +def test_reconnects_after_proc_exits(server) -> None: + async def main() -> int: + engine = AsyncControlModeEngine.for_server(server) + await engine.start() + gen0 = engine._generation + # simulate the control proc dying + assert engine._proc is not None + engine._proc.terminate() + await asyncio.sleep(1.5) # supervisor backoff + reconnect + # a fresh run must succeed over the reconnected proc + from libtmux.experimental.engines.base import CommandRequest + result = await engine.run(CommandRequest.from_args("list-sessions")) + await engine.aclose() + assert result.returncode == 0 + return engine._generation - gen0 + + bumped = asyncio.run(main()) + assert bumped >= 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/engines/test_async_control_mode_supervisor.py -v` +Expected: FAIL — `AttributeError: _desired_subscriptions` / no reconnect. + +- [ ] **Step 3: Write minimal implementation** + +Implement in `async_control_mode.py`: +- Add `__init__` fields: `self._desired_subscriptions: list[str] = []`, `self._desired_attach: list[str] = []`, `self._generation = 0`, `self._closing = False`, `self._supervisor_task: asyncio.Task[None] | None = None`. +- `add_subscription(spec)` appends `spec` if absent. `set_attach_targets(ids)` stores a copy. +- Replace the `start()` reader launch with a `_supervisor()` task. `_supervisor()`: + 1. spawn proc + `_consume_startup()` (extract the spawn from `start()` into `_spawn()`), + 2. `self._generation += 1`, + 3. replay: `await self.run_batch([CommandRequest.from_args("refresh-client", "-B", s) for s in self._desired_subscriptions])` (skip if empty), + 4. run the existing `_reader()` loop inline, + 5. on EOF/return when not `self._closing`: `self._parser = ControlModeParser()`, `self._fail_pending(...)`, jittered `await asyncio.sleep(backoff)` (e.g. `min(0.1 * 2**n, 5.0)` plus a small fixed jitter derived from `n`, never `random`), loop. +- `aclose()` sets `self._closing = True` first, cancels `_supervisor_task`. + +> Attach replay (`_desired_attach`) is wired in Task 10 (it lives in `events._ensure_attached`). For this task, replaying subscriptions + bumping generation + reconnect is sufficient. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/engines/test_async_control_mode_supervisor.py -v` +Expected: PASS (2 tests). Run the full control-mode suite for regressions: `uv run pytest tests/experimental/ -k control_mode -v`. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/engines/async_control_mode.py tests/experimental/engines/test_async_control_mode_supervisor.py +git commit -m "Engines(feat[async_control_mode]): Add supervised reconnect + desired-state replay" +``` + +--- + +### Task 10: Reset the sticky attach on reconnect + +**Files:** +- Modify: `src/libtmux/experimental/mcp/events.py` (`_ensure_attached`, ~line 374–396) +- Modify: `src/libtmux/experimental/engines/async_control_mode.py` (clear the flag on reconnect) +- Test: `tests/experimental/mcp/test_attach_reset.py` + +**Interfaces:** +- Produces: `_ensure_attached` stores the attached session on the engine and the engine's `_supervisor()` clears it on each (re)connect so the next `_ensure_attached` re-attaches (today the flag is sticky and a reconnect silently emits no `%output`). Add `AsyncControlModeEngine._reset_attach()` that sets `self._attached_session = None`; call it in `_supervisor()` right after `_spawn()`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/mcp/test_attach_reset.py +"""A reconnect must clear the sticky attach so %output flows again.""" + +from __future__ import annotations + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +def test_reset_attach_clears_flag() -> None: + engine = AsyncControlModeEngine() + engine._attached_session = "$0" + engine._reset_attach() + assert getattr(engine, "_attached_session", "sentinel") is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/mcp/test_attach_reset.py -v` +Expected: FAIL — `AttributeError: _reset_attach`. + +- [ ] **Step 3: Write minimal implementation** + +- In `async_control_mode.py`: add `self._attached_session: str | None = None` to `__init__`; add `def _reset_attach(self) -> None: self._attached_session = None`; call `self._reset_attach()` in `_supervisor()` immediately after spawning a fresh proc. +- In `events.py`: confirm `_ensure_attached` reads/writes `engine._attached_session` (it already does at lines 387/396); no change needed beyond the field now being declared on the engine (remove the `# type: ignore` since the attribute is now real). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/mcp/test_attach_reset.py tests/experimental/mcp/ -k event -v` +Expected: PASS, no event-tool regressions. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/engines/async_control_mode.py src/libtmux/experimental/mcp/events.py tests/experimental/mcp/test_attach_reset.py +git commit -m "Engines(fix[async_control_mode]): Reset sticky attach on reconnect" +``` + +--- + +### Task 11: `monitor.py` — `AgentMonitor` core + +**Files:** +- Create: `src/libtmux/experimental/agents/monitor.py` +- Test: `tests/experimental/agents/test_monitor.py` (unit, fake engine) + +**Interfaces:** +- Consumes: `AgentStore`/`apply`/`Observed`/`Vanished` (Task 3); `OptionSignal`/`OscSignal`/`Reading`/`SUBSCRIPTION` (Task 4); `is_alive` (Task 5); `panes_of`/`diff_panes`/`PANE_FORMAT` (Task 6); `Stamp`/`MonotonicCounter` (Task 2); `Agent`/`AgentState` (Task 1); an engine with `run`, `subscribe`, `add_subscription`, `set_attach_targets`. +- Produces: + - `AgentMonitor(engine, *, sink: Storage | None = None, clock: Clock | None = None)`. + - `agents` property → `list[Agent]` snapshot (from the coalescing store). + - `ingest(notification_raw: str) -> None` — classify one notification (option line → `Observed`; `%output` → feed `OscSignal`) and `apply()` with the `latest()` guard, stamping via the clock. + - `async start()` / `async stop()` / `async reconcile()` / `status() -> dict`. + - The reducer write site applies the `latest()` guard inside `apply` **before** the store overwrites (Task 3 guarantees this). + +- [ ] **Step 1: Write the failing test** (unit: drive `ingest` directly, no tmux) + +```python +# tests/experimental/agents/test_monitor.py +"""Unit tests for AgentMonitor.ingest (no live tmux).""" + +from __future__ import annotations + +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import AgentState + + +class _FakeEngine: + async def run(self, request): ... + async def subscribe(self): ... + def add_subscription(self, spec): ... + def set_attach_targets(self, ids): ... + + +def test_ingest_option_line_updates_agent() -> None: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + by_pane = {a.pane_id: a for a in mon.agents} + assert by_pane["%1"].state is AgentState.RUNNING + + +def test_ingest_osc_output_updates_agent() -> None: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%output %2 \033]3008;state=awaiting_input\033\\") + by_pane = {a.pane_id: a for a in mon.agents} + assert by_pane["%2"].state is AgentState.AWAITING_INPUT + + +def test_stale_does_not_clobber() -> None: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + # newest wins; both via the option writer so the second (newer counter) wins + by_pane = {a.pane_id: a for a in mon.agents} + assert by_pane["%1"].state is AgentState.IDLE +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/test_monitor.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +Implement `AgentMonitor` with: +- `__init__` stores engine, sink, `self._clock = clock or MonotonicCounter()`, `self._store = AgentStore()` (seeded from `sink.load()` if present), `self._osc = OscSignal()`. +- `ingest(raw)`: + - if `raw` starts with `%output `: split `"%output", pane, rest`; for each `Reading` from `self._osc.feed(pane, rest_bytes)` → `self._observe(reading)`. + - else try `OptionSignal.parse(raw)`; if a `Reading`, `self._observe(reading)`. +- `_observe(reading)`: build `Observed(pane_id=reading.pane_id, key=reading.pane_id, name=reading.name, state=reading.state, stamp=Stamp(self._clock(), reading.source), source=reading.source, pid=None)`; `self._store = apply(self._store, observed, now=)`; if `self._sink`: `self._sink.save(self._store.to_dict())`. +- `agents` property returns `list(self._store.agents.values())`. +- `async start()`: `self._engine.add_subscription(SUBSCRIPTION)`; record attach targets from a `list-sessions` (Task 12 wires the live loop); spawn a task draining `engine.subscribe()` into `ingest`. +- `async reconcile()`: run `list-panes -a -F ` via the engine, build a `ServerSnapshot`, diff vs the prior pane set, `apply(Vanished)` for removed panes; refresh `pid`/`alive` via `is_alive`. +- `status()` returns `{"agents": len(self._store.agents), "generation": getattr(engine, "_generation", 0)}`. + +> Keep `ingest` synchronous and pure-ish (mutating only `self._store`/`self._osc`) so the unit test needs no tmux. The async drain loop calls `ingest` per notification. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/test_monitor.py --doctest-modules src/libtmux/experimental/agents/monitor.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/monitor.py tests/experimental/agents/test_monitor.py +git commit -m "Agents(feat[monitor]): Add AgentMonitor ingest + store wiring" +``` + +--- + +### Task 12: `hooks/emit.py` — the shared emitter + console entry point + +**Files:** +- Create: `src/libtmux/experimental/agents/hooks/__init__.py` +- Create: `src/libtmux/experimental/agents/hooks/emit.py` +- Modify: `pyproject.toml` (add a `[project.scripts]` entry `libtmux-agent-emit`) +- Test: `tests/experimental/agents/hooks/__init__.py`, `tests/experimental/agents/hooks/test_emit.py` + +**Interfaces:** +- Produces: `emit(state: str, *, name: str | None = None, runner=subprocess.run, tty_path="/dev/tty", env=None) -> None` — when `$TMUX` is set in `env`, runs `tmux set-option -p -t $TMUX_PANE @agent_state ` (and `@agent_name` if `name`); else writes the `OSC 3008` escape to `tty_path`. `main(argv)` is the console entry point (`libtmux-agent-emit [--name NAME]`). + +- [ ] **Step 1: Write the failing test** (inject a fake runner + a tmp tty file) + +```python +# tests/experimental/agents/hooks/test_emit.py +"""Tests for the shared agent-state emitter.""" + +from __future__ import annotations + +from libtmux.experimental.agents.hooks.emit import emit + + +def test_local_uses_set_option() -> None: + calls: list = [] + emit( + "running", + runner=lambda argv, **kw: calls.append(argv), + env={"TMUX": "/tmp/x,1,0", "TMUX_PANE": "%4"}, + ) + assert calls[0][:5] == ["tmux", "set-option", "-p", "-t", "%4"] + assert calls[0][5:] == ["@agent_state", "running"] + + +def test_remote_writes_osc_to_tty(tmp_path) -> None: + tty = tmp_path / "tty" + tty.write_bytes(b"") + emit("idle", tty_path=str(tty), env={}) # no TMUX → remote path + data = tty.read_bytes() + assert b"\033]3008;state=idle\033\\" in data +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/hooks/test_emit.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```python +# src/libtmux/experimental/agents/hooks/__init__.py +"""Agent-side hook emitters + installers.""" + +from __future__ import annotations +``` + +```python +# src/libtmux/experimental/agents/hooks/emit.py +"""Emit an agent-state signal from inside an agent's lifecycle hook. + +Local (tmux reachable): write the ``@agent_state`` pane option. Remote (SSH): +write an ``OSC 3008`` escape to ``/dev/tty`` -- NOT stdout, which agent hooks +pipe/null -- so it reaches the pane pty and travels over SSH into tmux %output. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import typing as t + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +def emit( + state: str, + *, + name: str | None = None, + runner: t.Callable[..., t.Any] = subprocess.run, + tty_path: str = "/dev/tty", + env: Mapping[str, str] | None = None, +) -> None: + """Signal *state* for the current pane (local set-option, else remote OSC). + + Examples + -------- + >>> calls = [] + >>> emit("running", runner=lambda a, **k: calls.append(a), + ... env={"TMUX": "x", "TMUX_PANE": "%1"}) + >>> calls[0][:2] + ['tmux', 'set-option'] + """ + environ = os.environ if env is None else env + pane = environ.get("TMUX_PANE") + if environ.get("TMUX") and pane: + runner( + ["tmux", "set-option", "-p", "-t", pane, "@agent_state", state], + check=False, + ) + if name: + runner( + ["tmux", "set-option", "-p", "-t", pane, "@agent_name", name], + check=False, + ) + return + payload = f"state={state}" + if name: + payload += f";name={name}" + escape = f"\033]3008;{payload}\033\\".encode() + with open(tty_path, "wb", buffering=0) as tty: + tty.write(escape) + + +def main(argv: Sequence[str] | None = None) -> int: + """Console entry point: ``libtmux-agent-emit [--name NAME]``.""" + args = list(sys.argv[1:] if argv is None else argv) + if not args: + return 2 + state = args[0] + name = None + if "--name" in args: + name = args[args.index("--name") + 1] + emit(state, name=name) + return 0 +``` + +Add to `pyproject.toml` under `[project.scripts]`: + +```toml +libtmux-agent-emit = "libtmux.experimental.agents.hooks.emit:main" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/hooks/test_emit.py --doctest-modules src/libtmux/experimental/agents/hooks/emit.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/hooks/ tests/experimental/agents/hooks/ pyproject.toml +git commit -m "Agents(feat[hooks]): Add shared emitter (local set-option / remote OSC to tty)" +``` + +--- + +### Task 13: `hooks/base.py` + `hooks/registry.py` — the `AgentHook` protocol + registry + +**Files:** +- Create: `src/libtmux/experimental/agents/hooks/base.py` +- Create: `src/libtmux/experimental/agents/hooks/registry.py` +- Test: `tests/experimental/agents/hooks/test_registry.py` + +**Interfaces:** +- Produces: + - `EVENT_STATE: dict[str, str]` — canonical lifecycle→state map keyed by a neutral event name: `{"turn_start": "running", "needs_approval": "awaiting_input", "turn_end": "awaiting_input", "session_start": "idle"}`. + - `AgentHook` protocol: `name: str`, `detect() -> bool`, `install() -> None`, `uninstall() -> None`, `status() -> str` (`"installed"`/`"outdated"`/`"absent"`). + - `registry() -> list[AgentHook]` returning `[ClaudeCodeHook(), CodexHook()]` (imported lazily to avoid cycles). + - `get(name: str) -> AgentHook` (raises `KeyError` if unknown). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/hooks/test_registry.py +"""Tests for the hook registry + canonical event map.""" + +from __future__ import annotations + +from libtmux.experimental.agents.hooks.base import EVENT_STATE +from libtmux.experimental.agents.hooks.registry import get, registry + + +def test_event_state_map_is_canonical() -> None: + assert EVENT_STATE["turn_start"] == "running" + assert EVENT_STATE["needs_approval"] == "awaiting_input" + + +def test_registry_has_claude_and_codex() -> None: + names = {hook.name for hook in registry()} + assert {"claude", "codex"} <= names + + +def test_get_unknown_raises() -> None: + try: + get("nope") + except KeyError: + return + raise AssertionError("expected KeyError") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/hooks/test_registry.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +`base.py` defines `EVENT_STATE` and the `AgentHook` `t.Protocol`. `registry.py` imports `ClaudeCodeHook`/`CodexHook` (Tasks 14/15) lazily inside `registry()` and `get()`. (Implement `registry()`/`get()` now; the two hook classes are filled in next. To keep this task green standalone, stub `ClaudeCodeHook`/`CodexHook` as classes with `name` attributes and no-op `detect/install/uninstall/status` returning `"absent"`, then flesh them out in Tasks 14–15.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/hooks/test_registry.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/hooks/base.py src/libtmux/experimental/agents/hooks/registry.py tests/experimental/agents/hooks/test_registry.py +git commit -m "Agents(feat[hooks]): Add AgentHook protocol + registry + event→state map" +``` + +--- + +### Task 14: `hooks/claude.py` — Claude Code installer + +**Files:** +- Create: `src/libtmux/experimental/agents/hooks/claude.py` +- Test: `tests/experimental/agents/hooks/test_claude.py` + +**Interfaces:** +- Consumes: `EVENT_STATE` (Task 13). +- Produces: `ClaudeCodeHook(settings_path: pathlib.Path | None = None)` implementing `AgentHook`. `install()` merges hook entries into `~/.claude/settings.json` (`UserPromptSubmit`→running, `Notification`→awaiting_input, `Stop`→awaiting_input, `SessionStart`→idle), each running `libtmux-agent-emit `. `status()` returns `installed`/`outdated`/`absent`. `uninstall()` removes only our entries (never clobbers unrelated user hooks). + +- [ ] **Step 1: Write the failing test** (round-trip against a `tmp_path` settings file) + +```python +# tests/experimental/agents/hooks/test_claude.py +"""Tests for the Claude Code hook installer.""" + +from __future__ import annotations + +import json + +from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + + +def test_install_status_uninstall_roundtrip(tmp_path) -> None: + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"hooks": {"Stop": [{"hooks": [ + {"type": "command", "command": "echo user-owned"}]}]}})) + hook = ClaudeCodeHook(settings_path=settings) + + assert hook.status() == "absent" + hook.install() + assert hook.status() == "installed" + + data = json.loads(settings.read_text()) + stop_cmds = [ + h["command"] for grp in data["hooks"]["Stop"] for h in grp["hooks"] + ] + assert any("libtmux-agent-emit awaiting_input" in c for c in stop_cmds) + assert "echo user-owned" in stop_cmds # never clobber the user's hook + + hook.uninstall() + assert hook.status() == "absent" + data = json.loads(settings.read_text()) + stop_cmds = [ + h["command"] for grp in data["hooks"].get("Stop", []) for h in grp["hooks"] + ] + assert "echo user-owned" in stop_cmds # still there + + +def test_install_is_idempotent(tmp_path) -> None: + settings = tmp_path / "settings.json" + hook = ClaudeCodeHook(settings_path=settings) + hook.install() + hook.install() + assert hook.status() == "installed" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/hooks/test_claude.py -v` +Expected: FAIL — `ClaudeCodeHook` is the stub. + +- [ ] **Step 3: Write minimal implementation** + +Implement `ClaudeCodeHook`. Map Claude event → state: `{"UserPromptSubmit": "running", "Notification": "awaiting_input", "Stop": "awaiting_input", "SessionStart": "idle"}`. Tag our entries with a stable marker (e.g. `command` contains `libtmux-agent-emit`) so `status()`/`uninstall()` can find them without touching user entries. `install()` is idempotent (remove-then-add our entries). Default `settings_path` = `pathlib.Path.home() / ".claude" / "settings.json"`. Write atomically (reuse `JsonFile` from Task 3 or an equivalent temp+replace). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/hooks/test_claude.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/hooks/claude.py tests/experimental/agents/hooks/test_claude.py +git commit -m "Agents(feat[hooks]): Add Claude Code hook installer (non-clobbering)" +``` + +--- + +### Task 15: `hooks/codex.py` — Codex installer + +**Files:** +- Create: `src/libtmux/experimental/agents/hooks/codex.py` +- Test: `tests/experimental/agents/hooks/test_codex.py` + +**Interfaces:** +- Produces: `CodexHook(config_path: pathlib.Path | None = None)` implementing `AgentHook`. `install()` writes command hooks into Codex `[hooks]` TOML (`~/.codex/config.toml`): `user_prompt_submit`→running, `permission_request`→awaiting_input, `stop`→awaiting_input, `session_start`→idle, each a `{ type = "command", command = "libtmux-agent-emit " }`. Codex passes the event JSON on stdin, but each event registers a separate hook so the command hard-codes its state. `status()`/`uninstall()` are non-clobbering. Use a TOML lib already in the deps (`tomllib` for read on 3.11+, plus the project's existing TOML writer; if none, write the `[hooks]` block as text idempotently between marker comments). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/experimental/agents/hooks/test_codex.py +"""Tests for the Codex hook installer.""" + +from __future__ import annotations + +from libtmux.experimental.agents.hooks.codex import CodexHook + + +def test_install_writes_event_hooks(tmp_path) -> None: + config = tmp_path / "config.toml" + config.write_text("model = \"o4\"\n") # pre-existing unrelated config + hook = CodexHook(config_path=config) + + assert hook.status() == "absent" + hook.install() + assert hook.status() == "installed" + + text = config.read_text() + assert "user_prompt_submit" in text + assert "libtmux-agent-emit running" in text + assert "permission_request" in text + assert "libtmux-agent-emit awaiting_input" in text + assert "model = \"o4\"" in text # untouched + + hook.uninstall() + assert hook.status() == "absent" + assert "model = \"o4\"" in config.read_text() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/experimental/agents/hooks/test_codex.py -v` +Expected: FAIL — `CodexHook` is the stub. + +- [ ] **Step 3: Write minimal implementation** + +Implement `CodexHook`. Write our hooks between marker comments `# >>> libtmux-agent-state >>>` / `# <<< libtmux-agent-state <<<` so `status()`/`uninstall()` operate only on our block and preserve the rest of `config.toml` verbatim. Map: `{"user_prompt_submit": "running", "permission_request": "awaiting_input", "stop": "awaiting_input", "session_start": "idle"}`. Default `config_path` = `pathlib.Path.home() / ".codex" / "config.toml"`. Document the legacy `notify` fallback in the docstring (not implemented in v1; modern `[hooks]` is primary). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/experimental/agents/hooks/test_codex.py tests/experimental/agents/hooks/test_registry.py -v` +Expected: PASS (registry now sees a real CodexHook). + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/agents/hooks/codex.py tests/experimental/agents/hooks/test_codex.py +git commit -m "Agents(feat[hooks]): Add Codex [hooks] installer (marker-bounded, non-clobbering)" +``` + +--- + +### Task 16: MCP surface — `register_agents` + `list_agents`/`watch_agents`/`install_agent_hooks` + +**Files:** +- Create: `src/libtmux/experimental/mcp/vocabulary/agents.py` +- Modify: the MCP server builder to call `register_agents` (follow how `register_events` is wired — grep `register_events` in `mcp/fastmcp_adapter.py`/`mcp/registry.py`) +- Test: `tests/experimental/mcp/test_agents_tools.py` + +**Interfaces:** +- Consumes: `AgentMonitor` (Task 11); `registry`/`get` (Task 13); the engine + the `register_events` wiring pattern. +- Produces: `register_agents(mcp, engine, *, sink=None) -> AgentMonitor` that starts a monitor and registers three tools: `list_agents()` (snapshot list), `watch_agents(timeout_s)` (transition stream), `install_agent_hooks(agent)` (calls `get(agent).install()` then returns `status()`). + +- [ ] **Step 1: Write the failing test** (drive the monitor + the tool callables directly, no live MCP transport) + +```python +# tests/experimental/mcp/test_agents_tools.py +"""Tests for the agents MCP tools (callables driven directly).""" + +from __future__ import annotations + +from libtmux.experimental.agents.monitor import AgentMonitor + + +class _FakeEngine: + async def run(self, request): ... + async def subscribe(self): ... + def add_subscription(self, spec): ... + def set_attach_targets(self, ids): ... + + +def test_list_agents_reflects_ingested_state() -> None: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + listing = [ + {"pane_id": a.pane_id, "state": a.state.value} for a in mon.agents + ] + assert {"pane_id": "%1", "state": "running"} in listing +``` + +> The full `register_agents` wiring is integration-tested live in Task 17; this unit test pins the snapshot shape the `list_agents` tool returns. + +- [ ] **Step 2: Run test to verify it fails / passes** + +Run: `uv run pytest tests/experimental/mcp/test_agents_tools.py -v` +Expected: PASS once Task 11 exists (this asserts the data shape). Then implement `register_agents` and confirm it imports cleanly. + +- [ ] **Step 3: Write minimal implementation** + +Implement `vocabulary/agents.py::register_agents` mirroring `register_events`. The three tool callables read `monitor.agents` / iterate `engine.subscribe()` mapped through `monitor.ingest` for `watch_agents` / call the registry for installs. Wire `register_agents` into the async server builder next to `register_events`. + +- [ ] **Step 4: Run the suite** + +Run: `uv run pytest tests/experimental/mcp/ -v` +Expected: PASS, no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add src/libtmux/experimental/mcp/vocabulary/agents.py src/libtmux/experimental/mcp/ tests/experimental/mcp/test_agents_tools.py +git commit -m "Mcp(feat[agents]): Add list_agents/watch_agents/install_agent_hooks tools" +``` + +--- + +### Task 17: Live integration — observe state against a real tmux + +**Files:** +- Create: `tests/experimental/agents/test_live_monitor.py` + +**Interfaces:** +- Consumes: `AgentMonitor`, `AsyncControlModeEngine`, the `server`/`session` fixtures. + +- [ ] **Step 1: Write the live test** + +```python +# tests/experimental/agents/test_live_monitor.py +"""Live: a real @agent_state write becomes observable through the monitor.""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +def test_monitor_observes_running(session) -> None: + async def main() -> str: + engine = AsyncControlModeEngine.for_server(session.server) + monitor = AgentMonitor(engine) + await monitor.start() + pane_id = session.active_window.active_pane.pane_id + # the agent hook's effect, simulated: + session.cmd("set-option", "-p", "-t", pane_id, "@agent_state", "running") + # tmux's subscription timer is ~1 s; poll up to 3 s + for _ in range(30): + await asyncio.sleep(0.1) + match = {a.pane_id: a for a in monitor.agents}.get(pane_id) + if match is not None and match.state.value == "running": + break + await monitor.stop() + return match.state.value if match else "missing" + + assert asyncio.run(main()) == "running" +``` + +- [ ] **Step 2: Run it** + +Run: `uv run pytest tests/experimental/agents/test_live_monitor.py -v` +Expected: PASS (state observed within ~3 s). If flaky on the 1 s debounce, raise the poll budget — do not assert sub-second. + +- [ ] **Step 3: Commit** + +```bash +git add tests/experimental/agents/test_live_monitor.py +git commit -m "Agents(test): Live tmux test — @agent_state observed through the monitor" +``` + +--- + +### Task 18: Docs, CHANGES, Sphinx exclude, final gate + +**Files:** +- Modify: `docs/conf.py` (exclude `superpowers/**` from the build) or `docs/justfile` +- Modify: `docs/experimental.md` (add an Agents section linking the new symbols) +- Modify: `CHANGES` (a `#### Agent-state monitor` deliverable under `### What's new`) + +- [ ] **Step 1: Exclude the spec/plan from Sphinx** + +Add to `docs/conf.py`: `exclude_patterns += ["superpowers/**"]` (create `exclude_patterns` if absent). Verify: `just build-docs` emits no "not in any toctree" warning for the spec/plan. + +- [ ] **Step 2: Document the module** + +Add an `## Agents` section to `docs/experimental.md` with a short prose intro and autodoc/cross-refs for `AgentMonitor`, `AgentState`, `Agent`, the MCP tools. Include one working doctest-style usage block already covered by Task 17. + +- [ ] **Step 3: CHANGES entry** + +Under the unreleased `### What's new`, add `#### Agent-state monitor` with 1–2 prose paragraphs (user vocabulary: "see which agent in which pane needs you"), linking `{class}` / `{meth}` roles. + +- [ ] **Step 4: Run the full gate** + +```bash +rm -rf docs/_build && uv run ruff format . && uv run ruff check . --fix && uv run mypy src tests && uv run pytest --reruns 0 -vvv && just build-docs +``` + +Expected: all green. Re-run `test_retry_three_times` / build-docs in isolation if either flakes (known-flaky), per project guidance. + +- [ ] **Step 5: Commit** + +```bash +git add docs/ CHANGES +git commit -m "Agents(docs): Document the agent-state monitor + CHANGES entry" +``` + +--- + +## Self-Review + +**Spec coverage:** state model (T1–3), latest-wins/LWW (T2–3), two signals incl. fragmented OSC (T4), health/TTL probe (T5), derived tree + reconcile diff (T6), `refresh-client -B/-C` op (T7), death-sentinel false-`settled` fix (T8), supervisor reconnect/resubscribe (T9), sticky-attach reset (T10), monitor wiring (T11), shared emitter + `/dev/tty` (T12), hook protocol/registry (T13), Claude + Codex installers (T14–15), MCP tools incl. `install_agent_hooks` (T16), live test (T17), docs/CHANGES/Sphinx-exclude/gate (T18). The remote keepalive TTL sweep (D5) is represented by `is_alive` (T5) + the reconcile sweep (T11); the periodic timer firing is wired in T11's `reconcile()` and exercised live — acceptable for v1. The daemon/CLI hosts are out of v1 scope per the spec. + +**Type consistency:** `Stamp(counter, writer)`, `latest(current, incoming)`, `Observed`/`Vanished`, `apply(store, event, *, now)`, `Reading(pane_id, state, name, source)`, `Agent(pane_id, key, name, state, since, source, pid, alive)`, `emit(state, *, name, runner, tty_path, env)` are used identically across tasks. The engine gains `add_subscription`/`set_attach_targets`/`_reset_attach`/`_generation`/`_closing` (T9–10) consumed by T11/T16. + +**Placeholder scan:** the one `__import__("contextlib")` in Task 3 is flagged with an explicit replacement note; no `TBD`/`add error handling`/`similar to Task N` remain. diff --git a/docs/superpowers/specs/2026-06-26-agents-agent-state-monitor-design.md b/docs/superpowers/specs/2026-06-26-agents-agent-state-monitor-design.md new file mode 100644 index 000000000..b27a56305 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-agents-agent-state-monitor-design.md @@ -0,0 +1,263 @@ +# Design: `libtmux.experimental.agents` — a tmux-native agent-state monitor + +- **Date:** 2026-06-26 +- **Branch:** `engine-ops` +- **Status:** Approved for spec review → planning +- **Topic:** the orchestration spine of a "tmux-powered supacode clone", reframed as a headless, resilient, agent-state monitor over libtmux's experimental stack. + +--- + +## 1. Goal + +Build a headless module that knows, for every pane running a coding agent, **what that agent is doing** — `RUNNING` / `AWAITING_INPUT` / `IDLE` / `EXITED` / `UNKNOWN` — signaled *cooperatively* by the agent's own lifecycle hooks, observed over a single attached control-mode connection, reconciled against tmux as the authority, and exposed through the existing FastMCP server. + +This is the one capability that turns `tmux list-panes` into a supacode-style command center: *which of my parallel agents needs me right now?* Everything supacode adds on top (the macOS GUI, Ghostty embedding, the SwiftUI sidebar) is out of scope and unwanted; the orchestration spine it is built around is mostly already present on this branch (`engines`, `ops`, `workspace`, `mcp`). + +**v1 is a vertical slice:** the agent-state model + the two signaling sources + the *minimal* slice of connection-resilience the model cannot function without. The distributed-systems garnish is explicitly cut (see §12). + +--- + +## 2. Validated facts (live probe, tmux 3.6a) + +The design rests on observed behavior, not assumption. A throwaway-socket probe established: + +| Path | Result | +|---|---| +| **Local:** agent runs `tmux set-option -p @agent_state running`; an attached control client with `refresh-client -B 'agentstate:%*:#{@agent_state}'` receives `%subscription-changed agentstate $0 @0 1 %0 : running` | ✅ works; carries session/window/pane + value | +| **Local latency** | ~1 s (tmux's debounced subscription timer) | +| **Local reliability** | option is always re-queryable via `show-options -p -v` → a lost notification self-heals on reconcile | +| **Remote:** a bare `OSC 3008 ;state=running ST` printed in a pane appears verbatim in control-mode `%output` (even with `allow-passthrough off`) | ✅ works | +| **`%output` framing** | delivered **byte-fragmented** (often one byte per `%output` line) → the OSC parser must buffer per-pane and scan for boundaries | +| **Attach requirement** | the control client must `attach-session` to receive `%output` *and* subscriptions (the "attach gap") | + +Two complementary failure modes justify supporting **both** sources: the **option** path is slow (~1 s) but lossless/re-queryable (state lives in tmux); the **OSC** path is instant but rides the lossy `%output` stream and is the only path that survives SSH (a remote `tmux set-option` can't reach the local socket). + +--- + +## 3. Core principle: source of truth is split by kind + +The central design invariant; conflating the two halves is the mistake that sinks designs like this. + +- **Observed state** — the session/window/pane tree, layout, activity. **tmux is authoritative.** The monitor is a **projection/controller** (a tmux-backed read-model), never a competing store. Hydrate once via `list-panes -a -F` → `ServerSnapshot.from_pane_rows` (already in `models/snapshots.py`); keep it live by applying structural `%`-notifications. Never poll-on-loop; never treat this derived tree (`tree.py`) as truth. +- **Intent / run-state** — agent identity, agent state, and (later) the project↔worktree↔pane-role mapping. **The monitor is authoritative.** tmux holds none of this and has zero disk persistence (server death = total amnesia). This tier lives in the monitor's own (optional in v1) durable store and is reconstructable against a freshly-restarted empty server. + +**Litmus:** if tmux can report it, *derive* it; if it encodes what an agent/project *means* by a pane, *persist* it. + +**Reconciliation is one-directional and event-sourced:** + +``` +live tmux ──▶ engine.subscribe() ──▶ classify ──▶ pure apply(state, event) ──▶ durable store ──▶ derived tree ──▶ hosts +``` + +Watchers *emit*; only `apply()` mutates the store (load-bearing invariant). **Deltas** drive the fast path; a **full `list-*` snapshot diff** is the correctness backstop, run on connect / on drop / on a slow timer — because tmux's change feed has blind spots (pane-died/pane-exited, window-resized, pane-title-changed emit *no* notification; `refresh-client -B` is a ~1 Hz edge-triggered sample that misses `A→B→A`). This is the Hadoop pattern: notifications = heartbeat/edit-log (optimistic), `list-*` reconcile = block-report (authoritative). + +--- + +## 4. Locked decisions + +| # | Decision | +|---|---| +| **Naming** | `agents` / `AgentState`. Avoids tmux's `status`/`activity`/`monitor`/`state` vocabulary collisions; matches OpenHands `AgentState` (`RUNNING`/`AWAITING_USER_INPUT`). | +| **D1 — multi-host horizon** | *Insure now, single-host in v1.* Agent state is a per-pane latest-wins entry whose deltas are transport-shaped (`counter, writer, value`); the clock is a pluggable callable (monotonic counter now, HLC later); skew is bounded (reject samples older than a budget) so a future multi-host pivot is a clock+transport swap, not a rewrite. **No multi-host code ships in v1.** | +| **D2 — resilience scope** | Build the supervisor reconnect loop + death-sentinel + sticky-attach fix + idempotent reducer + reconcile. Cut the garnish (no cross-restart epoch/seq replay, no `refresh-client -A` pause/hysteresis, no origin-tag guard). | +| **D3 — agent-state data model** | One latest-wins map keyed by pane business-key; `writer = source` (`option`/`osc`, later `host:source`); the reducer applies the `(counter, writer)` `latest()` guard **before** writing the coalescing latest-value slot (resolves the arrival-order contradiction). | +| **D4 — persistence / runtime** | Runtime-agnostic core; **embedded-in-MCP** is the only v1 host. Durable store is a single atomic JSON checkpoint (temp+rename+fsync), scoped per `(socket, server-instance)`, under an XDG state dir — **optional in v1** (see §6). A best-effort **per-socket advisory lease** guards the two-clients-on-one-socket case. Daemon + one-shot CLI are deferred thin hosts. | +| **D5 — remote health / TTL** | Local panes expire via `os.kill(pid, 0)`. PID-less remote panes can't be swept, so the remote hook wrapper emits a periodic **keepalive** (re-emits the current `state` at a configurable interval); a remote pane is marked **stale** (not auto-`EXITED`) only after a TTL ≈ 2× that interval with no signal. Absent a keepalive, remote health is best-effort last-seen and a busy remote agent is **never** auto-expired (no false `EXITED`). | + +--- + +## 5. Module layout + +New package `src/libtmux/experimental/agents/`, depending only on the `AsyncTmuxEngine` protocol (`engines/base.py`) + a `Storage` sink + a `subscribe()` source: + +| Module | Responsibility | +|---|---| +| `state.py` | `AgentState` enum + the immutable `Agent` record (with `is_awaiting`/`is_running` helpers). Pure values. | +| `merge.py` | The "latest update wins" rule: a `Stamp(counter, writer)` ordering tag + `latest(current, incoming) -> bool`. Pluggable clock (counter now → HLC later). Makes state convergent, idempotent, out-of-order/duplicate-tolerant. | +| `store.py` | Durable value tier: frozen `AgentStore` + the pure `apply(state, event) -> state` reducer + `Storage` protocol + `JsonFile` (atomic write). `to_dict`/`from_dict` mirror `models/snapshots.py`. | +| `tree.py` | The live session→window→pane tree, derived from `ServerSnapshot.from_pane_rows`; targeted per-pane invalidation, full rebuild only on structural events / reconcile. | +| `signals.py` | `AgentSignal` protocol + `OptionSignal` (local, via `@agent_state`) + `OscSignal` (remote, via OSC 3008) — the two channels agents use to report state, both consuming `engine.subscribe()`. | +| `health.py` | Is the pane's process still alive: `is_alive(pid)` via `#{pane_pid}` + `os.kill(pid, 0)`, sweeping dead local panes; remote PID-less panes use the keepalive TTL. Never infers death from a missing notification. | +| `monitor.py` | `AgentMonitor` core: the supervisor loop, reducer pipeline, coalescing slots, the `start/stop/status/reconcile` contract, `agents` snapshot + async `watch()`. | +| `hooks/emit.py` | The shared emitter: `emit(state, name=None)` → local `tmux set-option -p -t $TMUX_PANE @agent_state ` when `$TMUX` is reachable, else remote OSC 3008 written to **`/dev/tty`** (the pane pty — survives SSH). Exposed as a console entry point. | +| `hooks/base.py` | `AgentHook` protocol (`name`, `detect()`, `install()`, `uninstall()`, `status()`) + the canonical event→`AgentState` map type. | +| `hooks/claude.py` | `ClaudeCodeHook`: transactional installer into Claude Code settings (`~/.claude/settings.json`). | +| `hooks/codex.py` | `CodexHook`: transactional installer into Codex `[hooks]` command hooks (`~/.codex/config.toml`), with the legacy `notify` program as a fallback for older Codex. | +| `hooks/registry.py` | The agent-hook registry (`ClaudeCodeHook`, `CodexHook`) used by the installer + the `install_agent_hooks` MCP tool. | + +Plus surgical changes outside the package: + +- `ops/_ops/refresh_client.py` — add typed `-B ` and `-C ` support (today `RefreshClient.args()` returns empty; `-B` is only a raw `CommandRequest` in `events.py`). +- `engines/async_control_mode.py` — the supervisor loop, death-sentinel broadcast, sticky-attach reset, `TaskGroup` peer supervision (§7). +- `mcp/vocabulary/agents.py` — `list_agents` / `watch_agents` tools + `register_agents(mcp, engine, sink)`. + +--- + +## 6. State model + +```python +class AgentState(str, enum.Enum): + RUNNING = "running" # working + AWAITING_INPUT = "awaiting_input" # paused, needs the human/orchestrator + IDLE = "idle" # alive, no active task + EXITED = "exited" # process gone (health sweep) + UNKNOWN = "unknown" # no signal observed yet + +@dataclasses.dataclass(frozen=True) +class Agent: + pane_id: str # live %N within a connection + key: str # durable business key; pane_id in v1 + name: str | None # agent identity (OSC 3008 name= / config); None until announced + state: AgentState + since: float # monotonic stamp of the last transition + source: str # "option" | "osc" + pid: int | None # pane_pid; None for remote/ssh + alive: bool + + @property + def is_awaiting(self) -> bool: return self.state is AgentState.AWAITING_INPUT + @property + def is_running(self) -> bool: return self.state is AgentState.RUNNING +``` + +**Latest-wins merge** (`merge.py`) — when two updates for the same pane race (out of order, replayed, or from both channels), keep the newer one: + +```python +Clock = t.Callable[[], int] # monotonic counter now; HLC later + +@dataclasses.dataclass(frozen=True, order=True) +class Stamp: + counter: int # logical clock; higher = newer + writer: str # tie-break when counters equal: "option"/"osc" (v1), "host:source" (multi-host) + +def latest(current: Stamp | None, incoming: Stamp) -> bool: + """True if *incoming* should replace *current* (it is strictly newer).""" + return current is None or incoming > current +``` + +The store keeps `pane_key -> (Stamp, AgentState)` and calls `latest()` **before** overwriting the coalescing slot — so a stale replayed update can never clobber a fresher one. + +**Durable vs derived (v1 is deliberately thin).** Because agents re-announce on every state change and tmux is the tree authority, **v1 needs no load-bearing durable state**: on restart the monitor rebuilds the tree from `list-*` and refills agent state from the next heartbeat. The `store.py`/`JsonFile` machinery is built (it becomes load-bearing for the milestone-3 worktree manager's intent mapping) but in v1 the checkpoint is an **optional seed** for instant restart UX. This further de-risks v1: correctness does not depend on persistence. + +--- + +## 7. Resilience: the supervisor + +The existing stack is **fail-fast, not self-healing** (verified): the reader calls `_mark_dead` and stops forever; death is invisible to `subscribe()` consumers (they hang); `_attached_session` is sticky so a reconnect silently emits no `%output`; `-B` subscriptions vanish on reconnect and are never replayed; backpressure is silent drop-oldest; a dead stream is mis-reported by `wait_for_output` as `settled` (a false *DONE*). v1 closes the slice the monitor needs. + +**`_supervisor()` loop** (single owner of engine health, gated on a `_closing` flag): + +1. **connect** — spawn `tmux -C`. +2. **reset** — fresh `ControlModeParser` + fail pending command futures (the only place permitted to break the `_pending`↔bytes lockstep). +3. **re-attach** — clear the sticky `_attached_session`, re-attach declared sessions (the one-time redraw re-seeds the tree). +4. **resubscribe** — replay the stored `refresh-client -B` specs (server-side-per-client; gone with the connection). +5. **full reconcile** — `list-sessions`/`list-windows -a`/`list-panes -a` → `ServerSnapshot.from_pane_rows` → diff vs the tree → emit synthetic add/remove/rename for whatever the stream missed; bump the **generation** counter. +6. **read** the notification stream. +7. on a non-`CancelledError`, non-`_closing` return/crash → jittered exponential backoff → goto 1. + +**Death sentinel.** On death/reconnect, broadcast a generation/death sentinel to every subscriber queue **and close the generators** — so `accumulate_until_settle` returns `stream_end` (not a false `settled`), `subscribe()` consumers end instead of hanging, and the pull ring re-syncs. + +**Backpressure (replaces drop-oldest).** Split by data shape: **agent-state / topology** → a coalescing latest-value slot per entity (`dict[pane_key -> (Stamp, AgentState)]`; the reducer overwrites *after* the `latest()` guard — only the newest state matters, so this is correct, not lossy; the reconcile snapshot is the authoritative refill). **Ordered `%output`** → the existing byte/time caps (`max_bytes`, settle timeout). *No* `refresh-client -A` pause/hysteresis in v1 (deferred until a demonstrated sustained-flood pane). + +**Structured concurrency.** Lift the long-lived peers (supervisor, MCP server, ring drainer) into an `asyncio.TaskGroup` (abort-siblings + aggregate); absorb transient connection death *below* the group in the supervisor; keep per-client consumers isolated so one client's failure can't abort the shared engine. Generalize the existing cancellation-safe teardown; close every subscriber generator via `contextlib.aclosing`. + +**Health (process aliveness, `health.py`).** `#{pane_dead}`/`#{pane_pid}` + a periodic `os.kill(pid, 0)` sweep that **preserves PID-less remote records** (marked *stale* on the D5 keepalive TTL, never auto-`EXITED`); never infer death from a missing notification. + +**Lease.** A best-effort per-`(socket)` advisory lock (`flock` on a state-dir file) acquired by the monitor; a second monitor on the same socket runs read-only or declines to attach/drive — guarding double-attach and double-drive. Convergence of the *observed tree* across monitors is free (each reconciles independently); the lease protects *intent*-tier actions. + +**Self-heal scorecard (honest):** + +| Failure | Recovers? | Note | +|---|---|---| +| Connection EOF mid-stream | ✅ | supervisor reconnect + reconcile; `%output` bytes lost in the dead window are gone (tree restored, not capture) | +| Lost notification under load | ⚠️ within reconcile cadence | catches *final* state, not the missed *transition* (fine for latest-wins agent state) | +| tmux server restart | ⚠️ | tree re-derived from `list-*`; agents re-announce; no scrollback restore (tmux limitation) | +| Daemon crash + restart | ⚠️ | atomic checkpoint + reconcile (v1: no durable state to corrupt) | +| Dead connection mis-read as DONE | ✅ | sentinel closes generators → `stream_end` | +| Two monitors on one socket | ⚠️ | lease → one read-only; without lease, double-attach (operational, not state-corruption in v1) | +| Pane dies w/o emitting idle | ⚠️ cadence | no notification exists; caught by pid sweep | +| SSH agent disconnect | ⚠️ | PID-less → D5 TTL declares it stale | + +--- + +## 8. The two agent-state sources + +Both consume `engine.subscribe()`; both write into the **same** per-pane latest-wins key with `writer = source`. + +**`OptionSignal` (local).** On (re)attach, install `refresh-client -B 'agentstate:%*:#{@agent_state}'` (and `…#{@agent_name}` for identity). Parse `%subscription-changed agentstate $S @W idx %P : VALUE` → `AgentState`. Reconcile via `show-options -p -v -t %P @agent_state`. Spec stored as desired-state and replayed on reconnect. + +**`OscSignal` (remote).** A per-pane byte accumulator scans `%output %P ` for `OSC 3008 … ST` (the probe proved `%output` is byte-fragmented, so boundary-scanning across frames is mandatory). Payload grammar: `state=` and `name=`; an optional `kind=notify;title=;body=` shape is parsed but routing is deferred (no headless notification sink in v1 — milestone 2). + +**Attribution** derives from *which pane tmux says emitted the signal*, never from an id embedded in agent text. Tie-break/precedence between the two signals is the `(counter, writer)` compare; both carry the agent's emit-time clock so a replayed stale OSC loses to a fresher option write. + +**Hook emitters (Claude Code + Codex, v1).** A single shared emitter (`hooks/emit.py`, exposed as a console entry point) does the transport choice; each agent's hooks just call it with a state: + +```bash +# local: tmux reachable +tmux set-option -p -t "$TMUX_PANE" @agent_state running +# remote (SSH): write the OSC to the pane pty, NOT stdout (hooks pipe/null stdout) +printf '\033]3008;state=running\033\\' > /dev/tty +``` + +The remote→`/dev/tty` detail is load-bearing: both Claude Code and Codex capture hook stdout, so an OSC on stdout never reaches the terminal; the controlling tty *is* the pane's pty, so `/dev/tty` reaches tmux (and travels over SSH). + +Per-agent installers map lifecycle events → `AgentState` and write the emitter invocation transactionally (detect / install / outdated / rollback): + +| Event | `ClaudeCodeHook` (`~/.claude/settings.json`) | `CodexHook` (`~/.codex/config.toml` `[hooks]`) | → state | +|---|---|---|---| +| turn starts | `UserPromptSubmit` | `user_prompt_submit` | `running` | +| needs approval | `Notification` | `permission_request` | `awaiting_input` | +| turn ends | `Stop` | `stop` | `awaiting_input` | +| session begins | `SessionStart` | `session_start` | `idle` | + +Both agents deliver the event as JSON on the hook command's stdin (verified in Codex `engine/command_runner.rs`), but because each event registers a *separate* hook, the command can hard-code its state and need not parse stdin. Codex's older single-program `notify` (turn-complete only → `awaiting_input`) is the `CodexHook` fallback. Remaining agents (Copilot/Kiro/OpenCode/Pi) are added as more `hooks/registry.py` entries in milestone 2. + +--- + +## 9. Runtime & MCP surface + +**Core contract** (every host calls identically): `start()` / `stop()` / `status()` / `reconcile()` (+ async siblings). The core owns no `asyncio.run`, argparse, fastmcp, signals, or pidfiles. + +**Embedded-in-MCP (PRIMARY, v1).** `register_agents(mcp, engine, sink)` registers alongside `register_events`, reusing the already-persistent `AsyncControlModeEngine`, its single `subscribe()` stream, the `_lifespan` startup preflight as a fail-fast gate, and the existing attach path. Three MCP tools: + +- **`list_agents()`** → snapshot: `[{pane_id, name, state, since, alive, source}]`, read from the coalescing slot (no tmux round-trip). Sortable "awaiting-input first" by the caller. +- **`watch_agents(timeout_s)`** → a stream of `AgentStateChanged` transitions (semantic counterpart to `watch_events`), terminating cleanly on `stream_end`. +- **`install_agent_hooks(agent)`** → run a `hooks/registry.py` installer (`claude` | `codex`) for the calling user; reports `installed` / `outdated` / `absent` per `AgentHook.status()`. + +**Daemon** (deferred) and **one-shot CLI** (deferred, `reconcile→print→exit` like `workspace load`) are thin hosts enabled by the same contract. + +--- + +## 10. Testing strategy + +Per repo conventions (functional tests, existing fixtures, no `pytest-asyncio`): + +- **Unit (no tmux):** feed raw `%subscription-changed` lines into `OptionSignal`; feed byte-fragmented OSC streams into `OscSignal`; feed events into `apply()`; property-test `merge.latest` ordering (idempotent/out-of-order/duplicate-tolerant); test `JsonFile` atomicity (temp+rename+fsync, crash-mid-write leaves the old file intact); `ClaudeCodeHook`/`CodexHook` install→status→uninstall round-trips against a `tmp_path` fake config dir (idempotent, rollback on failure, no clobber of unrelated user hooks). +- **Live (real tmux, `session` fixture):** `def test_agent_monitor_observes_running(session)` wrapping an `async def main()` driven by `asyncio.run` — set `@agent_state`, assert the monitor reports `RUNNING` within ~1.5 s (covers the 1 s debounce). A second live test kills+restarts the control connection and asserts reconnect+reconcile restores the tree. +- **Doctests** on every public symbol (`AgentState`, `Agent`, `AgentMonitor`, `latest`) using the `doctest_namespace` fixtures. +- **Gate** (per the user's pre-commit sequence): `rm -rf docs/_build` → `ruff check --fix` → `ruff format` → `mypy src tests` → `pytest --reruns 0 -vvv` → `just build-docs`. + +--- + +## 11. Build methodology + +Per the user's plan: **prototype the slice here → `git stash` the rough pass → rebuild clean** with the structure above now that the shape is known → run the full gate to confirm. The slice is deliberately small enough to build twice. + +--- + +## 12. Out of scope (v1) / future milestones + +- **Cut garnish:** cross-restart epoch/seq replay (the pull ring is in-memory), `refresh-client -A` pause/continue flow control, origin-tag self-write filtering (rely on idempotent `apply()` + reconcile). +- **Milestone 2:** more agents in the hook registry (Copilot/Kiro/OpenCode/Pi — Claude Code + Codex ship in v1); OSC notification routing/sink; `OscSignal` hardening. +- **Milestone 3:** the full repo/worktree manager (sibling git module shelling to `git worktree`) that spins a worktree-per-agent via the `workspace` builder and feeds panes into the monitor — at which point the durable intent mapping becomes load-bearing. +- **Later:** multi-host agent-state aggregation (deltas already transport-shaped; swap clock→HLC + bound skew); standalone daemon + one-shot CLI hosts; macOS/GUI surfaces (never — delegated to the human's terminal or a future TUI). + +--- + +## 13. Acceptance criteria for v1 + +1. A Claude Code **and** a Codex agent in a local tmux pane, with the installed hook, each drive `AgentState` transitions observable through `list_agents`/`watch_agents` within ~1.5 s. +2. An agent over SSH drives the same transitions via the OSC signal written to `/dev/tty`. +3. Killing the control connection (or the tmux server) does **not** freeze the monitor: it reconnects, re-attaches, resubscribes, reconciles, and never reports a dead stream as a false `settled`. +4. Two MCP clients on one socket do not double-attach/double-drive (lease). +5. The full gate passes: lint, types, all tests (unit + live), doctests, docs build. From 03eff6351cedf1044f3ba44eaf6e9d84be4920b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:22:53 -0500 Subject: [PATCH 180/223] Agents(fix): Monitor self-heals across reconnects + wires health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: AgentMonitor._drain ran a single subscribe() that ended on the engine's death-sentinel; the supervisor reconnected but nothing re-subscribed and reconcile ran only once in start(), so after any blip list_agents served a stale snapshot forever (broke acceptance #3 / D2). health.is_alive was dead code and the docs described behavior the code did not do. what: - monitor: replace _drain with a supervised _run loop that reconciles FIRST each iteration (so subscribe() only runs against a live engine), then drains until the stream ends; on disconnect it retries reconcile with a bounded _reconnect_poll until the supervisor reconnects (replaying subs + attach), then re-subscribes. Split reconcile() into a defensive public wrapper + a raising _reconcile_once so the loop can wait for the engine to revive. stop() sets _stopping then cancels (no hang on a closed engine). - monitor: wire health in reconcile via _apply_health — refresh each tracked agent's pid/alive from the pane tree; mark a LOCAL pane (pid set) EXITED when its process is dead; never auto-EXIT a PID-less remote pane (D5). Note the receive-time clock seam (D1). - tests: add live test_monitor_survives_engine_reconnect (kill the control proc, confirm a NEW state is observed after reconnect) and unit tests for _apply_health (dead-local→EXITED, live refresh, pidless-never-exits). - docs/experimental.md: correct the liveness, reconcile-on-reconnect, and hook-install (settings.json / config.toml + libtmux-agent-emit, not set-hook) descriptions; add an AgentMonitor usage snippet. --- docs/experimental.md | 54 ++++-- src/libtmux/experimental/agents/monitor.py | 172 ++++++++++++++---- .../experimental/agents/test_live_monitor.py | 76 ++++++++ tests/experimental/agents/test_monitor.py | 41 +++++ 4 files changed, 299 insertions(+), 44 deletions(-) diff --git a/docs/experimental.md b/docs/experimental.md index 83a814d4d..d7df91326 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -187,23 +187,55 @@ control-mode engine, classifies incoming tmux notifications, and coalesces them into a per-pane {class}`~libtmux.experimental.agents.state.Agent` record — carrying the agent's name, its current {class}`~libtmux.experimental.agents.state.AgentState` (`RUNNING`, `AWAITING_INPUT`, `IDLE`, `EXITED`, or `UNKNOWN`), the timestamp of -the last transition, and a liveness flag updated by a periodic health probe. +the last transition, and a liveness flag refreshed from the pane tree on each +reconcile. A *local* pane whose process has exited is marked `EXITED`; a +*remote*, PID-less pane (e.g. over SSH) is never auto-expired by the liveness +check — those age out on a keepalive TTL instead. Agents report their state via tmux option subscriptions or OSC escape sequences. When both signals arrive for the same pane the monitor applies a -last-writer-wins merge so the store stays consistent without locks. A full-pane -reconciliation sweep runs every few seconds to catch any pane the stream missed, -compare it against the stored snapshot, and emit the minimal diff — so the -monitor self-heals across reconnects and supervisor restarts. +last-writer-wins merge so the store stays consistent without locks. On every +engine (re)connect the monitor runs a full-pane reconciliation — it lists all +panes, compares them against the stored snapshot, emits the minimal diff for +panes that vanished, and refreshes liveness — then re-subscribes to the +notification stream. Because this runs on each reconnect (not on a fixed +timer), the monitor self-heals across a tmux restart or socket blip: a dropped +connection never leaves the store serving a stale snapshot. + +```python +import asyncio + +from libtmux import Server +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +async def main() -> None: + engine = AsyncControlModeEngine.for_server(Server()) + monitor = AgentMonitor(engine) + await monitor.start() + try: + for agent in monitor.agents: + print(agent.pane_id, agent.state, "awaiting" if agent.is_awaiting else "") + finally: + await monitor.stop() + + +asyncio.run(main()) +``` ### Installing agent hooks -Before a coding agent can report state, its shell hooks must be installed into -the tmux session. {class}`~libtmux.experimental.agents.hooks.base.AgentHook` -subclasses (`ClaudeCodeHook`, `CodexHook`) write the necessary `after-*` hooks -into the running session via a tmux `set-hook` call. The MCP tool -`install_agent_hooks` does this on demand — pass `"claude"` or `"codex"` as the -agent name and the tool installs the hooks in the monitor's target session. +Before a coding agent can report state, its lifecycle hooks must be installed. +The {class}`~libtmux.experimental.agents.hooks.base.AgentHook` subclasses do not +touch tmux: `ClaudeCodeHook` merges hook entries into `~/.claude/settings.json` +and `CodexHook` into `~/.codex/config.toml`, leaving the rest of each file +untouched. Every installed hook runs the `libtmux-agent-emit` console script on +the agent's lifecycle events, and that script is what writes the agent's state +to tmux — a per-pane `@agent_state` option locally, or an OSC 3008 escape +sequence over SSH — exactly the signals the monitor subscribes to. The MCP tool +`install_agent_hooks` runs the matching installer on demand — pass `"claude"` or +`"codex"` as the agent name. ### MCP tools diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 29670f4be..0b386f078 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -7,20 +7,25 @@ :func:`~libtmux.experimental.agents.store.apply`. The async half (:meth:`AgentMonitor.start`, :meth:`AgentMonitor.stop`, -:meth:`AgentMonitor.reconcile`) wires the live engine subscribe loop and -performs a periodic full-pane reconciliation to catch panes the stream missed. +:meth:`AgentMonitor.reconcile`) wires the live engine subscribe loop in a +supervised task that re-subscribes and reconciles on every engine (re)connect, +so a tmux restart or socket blip can never leave the store serving a stale +snapshot. """ from __future__ import annotations import asyncio import contextlib +import dataclasses import logging import time import typing as t +from libtmux.experimental.agents.health import is_alive from libtmux.experimental.agents.merge import MonotonicCounter, Stamp from libtmux.experimental.agents.signals import SUBSCRIPTION, OptionSignal, OscSignal +from libtmux.experimental.agents.state import AgentState from libtmux.experimental.agents.store import ( AgentStore, Observed, @@ -35,7 +40,7 @@ from libtmux.experimental.agents.signals import Reading from libtmux.experimental.agents.state import Agent from libtmux.experimental.agents.store import AgentStore - from libtmux.experimental.models.snapshots import ServerSnapshot + from libtmux.experimental.models.snapshots import PaneSnapshot, ServerSnapshot logger = logging.getLogger(__name__) @@ -86,6 +91,11 @@ def __init__( self._osc = OscSignal() self._prev_panes: dict[str, t.Any] = {} self._task: asyncio.Task[None] | None = None + # Supervised-drain control: stop() flips this; _run() exits its loop. + self._stopping = False + # Bounded poll between reconcile retries while the engine is reconnecting, + # so the supervised drain waits (not busy-spins) for the engine to revive. + self._reconnect_poll = 0.5 # Seed the store from a persistent sink when one is provided. if sink is not None: @@ -155,6 +165,10 @@ def _observe(self, reading: Reading) -> None: key=reading.pane_id, name=reading.name, state=reading.state, + # The counter is assigned at RECEIVE time — correct for the single + # stream / single host this monitor drives. A future multi-host pivot + # needs an emit-time clock carried in the wire format too (so ordering + # survives across hosts), not merely swapping this Clock implementation. stamp=Stamp(self._clock(), reading.source), source=reading.source, pid=None, @@ -243,8 +257,10 @@ async def start(self) -> None: across reconnects. The ``%*`` subscription still installs across all panes, but per-pane option signals for *other* sessions' panes need their own attached client — a known v1 limitation. - 3. Spawn the drain task feeding every notification into :meth:`ingest`, - then run an initial :meth:`reconcile` to sync the pane tree. + 3. Run an initial :meth:`reconcile` to sync the pane tree, then spawn the + supervised drain task (:meth:`_run`), which re-subscribes and + reconciles on every engine (re)connect so a tmux restart or socket + blip can never leave the store stale. All attach steps are defensive: a failed ``list-sessions`` or ``attach-session`` is logged and skipped so :meth:`start` never crashes. @@ -268,8 +284,11 @@ async def start(self) -> None: except Exception: logger.debug("monitor attach-session failed", exc_info=True) - self._task = asyncio.get_running_loop().create_task(self._drain()) + # Sync the tree once before returning (callers expect a ready snapshot), + # then hand off to the supervised drain for the engine's whole lifetime. + self._stopping = False await self.reconcile() + self._task = asyncio.get_running_loop().create_task(self._run()) async def _primary_session_id(self) -> str | None: """Return the first *real* session id to attach to, or ``None``. @@ -346,7 +365,14 @@ async def _own_session_id(self) -> str | None: return None async def stop(self) -> None: - """Cancel the drain task and optionally flush the sink.""" + """Stop the supervised drain task and optionally flush the sink. + + Flips :attr:`_stopping` first so the loop will not re-enter, then cancels + the task. The cancel interrupts a ``subscribe()`` parked on + ``queue.get()`` even when the engine is permanently closed (no more + stream-end sentinels will arrive), so :meth:`stop` never hangs. + """ + self._stopping = True if self._task is not None: self._task.cancel() with contextlib.suppress(asyncio.CancelledError): @@ -356,42 +382,122 @@ async def stop(self) -> None: self._sink.save(self._store.to_dict()) async def reconcile(self) -> None: - """Reconcile the store against the live pane tree. + """Reconcile the store against the live pane tree (defensive). - Runs ``list-panes -a -F`` via the engine, diffs the result against - the previously seen pane set, and applies :class:`Vanished` for any - panes that have disappeared. This is defensive: any error from the - engine is caught and logged so the monitor stays alive. + Public, never-raising wrapper around :meth:`_reconcile_once`: any error + from the engine or parse is caught and logged so a direct caller (or the + initial sync in :meth:`start`) stays alive. The supervised drain calls + :meth:`_reconcile_once` directly instead, so it can *retry* on failure. """ try: - from libtmux.experimental.engines.base import CommandRequest - from libtmux.experimental.models.snapshots import ServerSnapshot - - fmt_str = _PANE_FORMAT_STR - req = CommandRequest.from_args("list-panes", "-a", "-F", fmt_str) - result = await self._engine.run(req) - rows = _parse_pane_rows(result.stdout) - snapshot: ServerSnapshot = ServerSnapshot.from_pane_rows(rows) - current_panes = panes_of(snapshot) - _added, removed = diff_panes(self._prev_panes, current_panes) - for pane_id in removed: - self._store = apply( - self._store, - Vanished(pane_id=pane_id), - now=time.monotonic(), - ) - self._prev_panes = dict(current_panes) + await self._reconcile_once() except Exception: logger.debug("reconcile skipped — engine call failed", exc_info=True) + async def _reconcile_once(self) -> None: + """Reconcile against the live pane tree; **raises** on engine failure. + + Runs ``list-panes -a -F`` via the engine, diffs the result against the + previously seen pane set, applies :class:`Vanished` for panes that + disappeared, then runs the health sweep (:meth:`_apply_health`). Lets the + engine error propagate (e.g. the dead-window ``ControlModeError``) so the + supervised drain can wait for the engine to revive before re-subscribing. + """ + from libtmux.experimental.engines.base import CommandRequest + from libtmux.experimental.models.snapshots import ServerSnapshot + + req = CommandRequest.from_args("list-panes", "-a", "-F", _PANE_FORMAT_STR) + result = await self._engine.run(req) + rows = _parse_pane_rows(result.stdout) + snapshot: ServerSnapshot = ServerSnapshot.from_pane_rows(rows) + current_panes = panes_of(snapshot) + _added, removed = diff_panes(self._prev_panes, current_panes) + for pane_id in removed: + self._store = apply( + self._store, + Vanished(pane_id=pane_id), + now=time.monotonic(), + ) + self._apply_health(current_panes) + self._prev_panes = dict(current_panes) + + def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: + """Refresh tracked agents' ``pid``/``alive`` from the pane tree. + + For each tracked agent still present in *current_panes*, copy the live + ``pane_pid`` from the snapshot and probe it with + :func:`~libtmux.experimental.agents.health.is_alive`: + + - A **local** pane (``pid`` is not ``None``) whose process is dead is + marked :attr:`~..state.AgentState.EXITED` (``alive=False``). + - A **remote / PID-less** pane (``pid`` is ``None``) is *never* + auto-EXITED — those expire on a keepalive TTL, not a PID probe (D5). + - Otherwise the agent's ``pid`` is refreshed and ``alive`` set ``True``. + + Panes absent from *current_panes* are left untouched here; their removal + is handled by the :class:`Vanished` diff in :meth:`_reconcile_once`. + + Parameters + ---------- + current_panes : dict[str, PaneSnapshot] + The live ``{pane_id: PaneSnapshot}`` map from this reconcile. + """ + agents = dict(self._store.agents) + changed = False + now = time.monotonic() + for pane_id, agent in self._store.agents.items(): + pane = current_panes.get(pane_id) + if pane is None: + continue # not in the tree → Vanished handles it + pid = pane.pid + if pid is not None and not is_alive(pid): + if agent.alive or agent.state is not AgentState.EXITED: + agents[pane_id] = dataclasses.replace( + agent, + state=AgentState.EXITED, + alive=False, + pid=pid, + since=now, + ) + changed = True + elif agent.pid != pid or not agent.alive: + agents[pane_id] = dataclasses.replace(agent, pid=pid, alive=True) + changed = True + if changed: + self._store = AgentStore(agents=agents, stamps=dict(self._store.stamps)) + # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ - async def _drain(self) -> None: - """Background task: forward every engine notification to :meth:`ingest`.""" - async for note in self._engine.subscribe(): - self.ingest(note.raw) + async def _run(self) -> None: + """Supervised drain: re-subscribe + reconcile across engine reconnects. + + Each iteration reconciles **first** (so ``subscribe()`` only runs against + a live engine), then drains the notification stream until it ends. When + the engine disconnects the stream ends via its ``_STREAM_END`` sentinel; + the loop comes back around, :meth:`_reconcile_once` retries until the + supervisor has reconnected (and replayed subscriptions + attach), then a + fresh ``subscribe()`` re-registers against the reconnected engine. This + is the self-heal that keeps the store live across a tmux restart or + socket blip. :meth:`stop` flips :attr:`_stopping` and cancels the task. + """ + while not self._stopping: + try: + await self._reconcile_once() + except Exception: + logger.debug("agents: reconcile not ready, retrying", exc_info=True) + await asyncio.sleep(self._reconnect_poll) + continue + try: + async with contextlib.aclosing(self._engine.subscribe()) as stream: + async for note in stream: + self.ingest(note.raw) + except asyncio.CancelledError: + raise + except Exception: + logger.debug("agents: drain error", exc_info=True) + # Stream ended (disconnect/sentinel): loop → reconcile + re-subscribe. def _parse_pane_rows( diff --git a/tests/experimental/agents/test_live_monitor.py b/tests/experimental/agents/test_live_monitor.py index 66c1852cb..d11df7518 100644 --- a/tests/experimental/agents/test_live_monitor.py +++ b/tests/experimental/agents/test_live_monitor.py @@ -13,6 +13,30 @@ from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +async def _poll_state( + monitor: AgentMonitor, + pane_id: str, + want: str, + *, + budget: float = 3.0, +) -> str: + """Poll the monitor until *pane_id* reports *want*, or *budget* elapses. + + tmux's subscription timer is ~1s, so callers pass a generous budget rather + than asserting sub-second latency. + """ + deadline = asyncio.get_running_loop().time() + budget + seen = "missing" + while asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.1) + match = {a.pane_id: a for a in monitor.agents}.get(pane_id) + if match is not None: + seen = match.state.value + if seen == want: + return seen + return seen + + def test_monitor_observes_running(session: Session) -> None: """@agent_state option set on a real pane is observed within 3s. @@ -115,3 +139,55 @@ async def main() -> None: ) asyncio.run(main()) + + +def test_monitor_survives_engine_reconnect(session: Session) -> None: + """The monitor keeps observing state after the control proc is killed. + + Proves the self-heal (the supervised drain re-subscribes + reconciles on + reconnect, and the engine supervisor replays the subscription + attach): + + 1. Start the monitor, observe ``@agent_state running``. + 2. Kill the control-mode process out from under it. + 3. Wait for the supervisor to reconnect. + 4. Set a NEW state (``awaiting_input``) and assert the monitor observes it — + only possible if it re-subscribed against the reconnected engine. + + Timing-tolerant: generous poll budgets, no sub-second assertions. + """ + + async def main() -> str: + engine = AsyncControlModeEngine.for_server(session.server) + monitor = AgentMonitor(engine) + await monitor.start() + assert engine._attached_session is not None + active_pane = session.active_window.active_pane + assert active_pane is not None + pane_id = active_pane.pane_id + assert pane_id is not None + + # 1. Observe the initial state. + session.cmd("set-option", "-p", "-t", pane_id, "@agent_state", "running") + assert await _poll_state(monitor, pane_id, "running", budget=4.0) == "running" + + gen_before = engine._generation + + # 2. Kill the control proc out from under the monitor. + assert engine._proc is not None + engine._proc.terminate() + + # 3. Wait for the supervisor to reconnect (backoff + respawn + replay). + for _ in range(40): + await asyncio.sleep(0.1) + if engine._generation > gen_before: + break + assert engine._generation > gen_before, "engine did not reconnect" + + # 4. A NEW state must be observable through the re-subscribed monitor. + session.cmd("set-option", "-p", "-t", pane_id, "@agent_state", "awaiting_input") + observed = await _poll_state(monitor, pane_id, "awaiting_input", budget=6.0) + await monitor.stop() + await engine.aclose() + return observed + + assert asyncio.run(main()) == "awaiting_input" diff --git a/tests/experimental/agents/test_monitor.py b/tests/experimental/agents/test_monitor.py index ce2160e58..72b80b5bf 100644 --- a/tests/experimental/agents/test_monitor.py +++ b/tests/experimental/agents/test_monitor.py @@ -2,8 +2,11 @@ from __future__ import annotations +import os + from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.models.snapshots import PaneSnapshot class _FakeEngine: @@ -40,3 +43,41 @@ def test_stale_does_not_clobber() -> None: # newest wins; both via the option writer so the second (newer counter) wins by_pane = {a.pane_id: a for a in mon.agents} assert by_pane["%1"].state is AgentState.IDLE + + +def _pane(pane_id: str, pid: int | None) -> PaneSnapshot: + """Build a minimal PaneSnapshot carrying just the pane id and pid.""" + return PaneSnapshot(pane_id=pane_id, pid=pid) + + +def test_apply_health_marks_dead_local_pane_exited() -> None: + """A local pane (pid set) whose process is dead is marked EXITED.""" + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + # 0x7FFFFFFE is almost certainly not a live process (see test_health). + mon._apply_health({"%1": _pane("%1", 2_147_483_646)}) + agent = {a.pane_id: a for a in mon.agents}["%1"] + assert agent.state is AgentState.EXITED + assert agent.alive is False + assert agent.pid == 2_147_483_646 + + +def test_apply_health_refreshes_live_local_pane() -> None: + """A local pane with a live pid keeps its state; pid/alive are refreshed.""" + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + mon._apply_health({"%1": _pane("%1", os.getpid())}) + agent = {a.pane_id: a for a in mon.agents}["%1"] + assert agent.state is AgentState.RUNNING + assert agent.alive is True + assert agent.pid == os.getpid() + + +def test_apply_health_never_exits_pidless_remote_pane() -> None: + """A PID-less (remote) pane is never auto-EXITED by the health sweep (D5).""" + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%output %2 \033]3008;state=running\033\\") + mon._apply_health({"%2": _pane("%2", None)}) + agent = {a.pane_id: a for a in mon.agents}["%2"] + assert agent.state is AgentState.RUNNING + assert agent.alive is True From 60eb3970275e85f4b6c374dada8258b15581371e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:28:57 -0500 Subject: [PATCH 181/223] Agents(docs): Correct remote-pane expiry wording (no v1 TTL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The "keepalive TTL" / "age out" wording described a mechanism that does not exist in v1 — there is no TTL, keepalive, or staleness timer. Remote PID-less panes are simply never auto-expired; they are left at last-known state and become EXITED only via the Vanished diff when their tmux pane actually disappears. what: - docs/experimental.md: reword the reconcile paragraph to drop the keepalive-TTL claim and state the real behavior. - monitor._apply_health docstring: remote pid-less panes expire only via the Vanished/pane-gone path, not a TTL. - health.py module docstring: this probe never declares remote panes dead; a keepalive/TTL is a possible future enhancement, not v1. --- docs/experimental.md | 7 ++++--- src/libtmux/experimental/agents/health.py | 5 +++-- src/libtmux/experimental/agents/monitor.py | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/experimental.md b/docs/experimental.md index d7df91326..58d5f2f0f 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -188,9 +188,10 @@ into a per-pane {class}`~libtmux.experimental.agents.state.Agent` record — carrying the agent's name, its current {class}`~libtmux.experimental.agents.state.AgentState` (`RUNNING`, `AWAITING_INPUT`, `IDLE`, `EXITED`, or `UNKNOWN`), the timestamp of the last transition, and a liveness flag refreshed from the pane tree on each -reconcile. A *local* pane whose process has exited is marked `EXITED`; a -*remote*, PID-less pane (e.g. over SSH) is never auto-expired by the liveness -check — those age out on a keepalive TTL instead. +reconcile. A *local* pane whose process has exited is marked `EXITED`. Remote +(SSH) panes have no local pid to probe, so they are left at their last-known +state and only become `EXITED` when their tmux pane disappears (no keepalive/TTL +in v1). Agents report their state via tmux option subscriptions or OSC escape sequences. When both signals arrive for the same pane the monitor applies a diff --git a/src/libtmux/experimental/agents/health.py b/src/libtmux/experimental/agents/health.py index 95bbcdeea..0c21c2b17 100644 --- a/src/libtmux/experimental/agents/health.py +++ b/src/libtmux/experimental/agents/health.py @@ -1,8 +1,9 @@ """Is the process behind a pane still alive. Local panes carry a ``pane_pid`` we can probe with ``os.kill(pid, 0)``. Remote -(SSH) panes are PID-less; this check never declares them dead — they expire on a -keepalive TTL owned by the monitor instead. +(SSH) panes are PID-less; this probe never declares them dead. In v1 they are +simply left at their last-known state (a keepalive/TTL expiry is a possible +future enhancement, not current behavior). """ from __future__ import annotations diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 0b386f078..9bc24cb18 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -431,7 +431,9 @@ def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: - A **local** pane (``pid`` is not ``None``) whose process is dead is marked :attr:`~..state.AgentState.EXITED` (``alive=False``). - A **remote / PID-less** pane (``pid`` is ``None``) is *never* - auto-EXITED — those expire on a keepalive TTL, not a PID probe (D5). + auto-EXITED by this probe — it is left at its last-known state and + only becomes EXITED via the :class:`Vanished` diff when its tmux pane + actually disappears (no keepalive/TTL in v1) (D5). - Otherwise the agent's ``pid`` is refreshed and ``alive`` set ``True``. Panes absent from *current_panes* are left untouched here; their removal From 37f9b3d364e015bdeb7bb8ca2fcd497afd61dea2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:30:14 -0500 Subject: [PATCH 182/223] Mcp(fix[lifespan]): Nest monitor start in try/finally why: If monitor.start() raised, the existing finally block was skipped because start() ran between the two try blocks, leaking the drain task. what: - Move monitor.start() + yield inside try/finally within the if monitor is not None: branch so stop() always runs on exit - Add else: yield branch to cover the monitor=None path --- src/libtmux/experimental/mcp/_lifespan.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/mcp/_lifespan.py b/src/libtmux/experimental/mcp/_lifespan.py index 2161111a0..cbec8fa24 100644 --- a/src/libtmux/experimental/mcp/_lifespan.py +++ b/src/libtmux/experimental/mcp/_lifespan.py @@ -53,11 +53,12 @@ async def _lifespan(_app: FastMCP) -> AsyncIterator[None]: msg = f"tmux engine preflight failed: {error}" raise RuntimeError(msg) from error if monitor is not None: - await monitor.start() - try: - yield - finally: - if monitor is not None: + try: + await monitor.start() + yield + finally: await monitor.stop() + else: + yield return _lifespan From 26e4cc97d4e438d1a6be117692679ac57d766650 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:31:38 -0500 Subject: [PATCH 183/223] Mcp(fix[agents]): Skip agent tools when lifespan won't start monitor why: With lifespan=False the monitor is never started, so registering agent tools against it yields a list_agents that silently returns []. what: - Guard register_agents call with `monitor_enabled and lifespan` so tools are only wired when the lifespan will actually start them --- src/libtmux/experimental/mcp/fastmcp_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index a13068c0e..c93a58b4b 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -769,7 +769,7 @@ def build_async_server( register_resources(mcp, engine, is_async=True) register_events(mcp, engine, mode=events, source=event_source) - if monitor_enabled: + if monitor_enabled and lifespan: from libtmux.experimental.mcp.vocabulary.agents import register_agents register_agents(mcp, engine, monitor=agent_monitor) From 34d6d66b4d2d662cd2d9e23f224e14d826a18012 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:32:38 -0500 Subject: [PATCH 184/223] Agents(fix[hooks]): Collapse duplicate Codex marker blocks on install why: _BLOCK_RE.sub(new_block, content) replaces every match, so a manually-malformed config with two marker blocks produced two copies. what: - Rewrite the existing-block branch to strip ALL marker blocks (via _BLOCK_WITH_SEP_RE then _BLOCK_RE for start-of-file), then append exactly one fresh block - Add test seeding a two-block config and asserting install collapses to one block with status "installed" --- src/libtmux/experimental/agents/hooks/codex.py | 13 ++++++++++++- tests/experimental/agents/hooks/test_codex.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/agents/hooks/codex.py b/src/libtmux/experimental/agents/hooks/codex.py index 95d9a4ef3..b2c0e4ab6 100644 --- a/src/libtmux/experimental/agents/hooks/codex.py +++ b/src/libtmux/experimental/agents/hooks/codex.py @@ -276,7 +276,18 @@ def install(self) -> None: content = self._read() new_block = self._build_block() if _MARKER_START in content: - content = _BLOCK_RE.sub(new_block, content) + # Strip ALL existing marker blocks (handles duplicates from a + # manually-malformed config) then append exactly one fresh block. + content = _BLOCK_WITH_SEP_RE.sub("", content) + if _MARKER_START in content: + # Any block at the very start of file has no preceding newline. + content = _BLOCK_RE.sub("", content) + if content: + if not content.endswith("\n"): + content += "\n" + content = content + "\n" + new_block + else: + content = new_block elif content: if not content.endswith("\n"): content += "\n" diff --git a/tests/experimental/agents/hooks/test_codex.py b/tests/experimental/agents/hooks/test_codex.py index c8e61acd2..3362697ce 100644 --- a/tests/experimental/agents/hooks/test_codex.py +++ b/tests/experimental/agents/hooks/test_codex.py @@ -48,3 +48,20 @@ def test_install_writes_event_hooks(tmp_path: pathlib.Path) -> None: hook.uninstall() assert hook.status() == "absent" assert 'model = "o4"' in config.read_text() + + +def test_install_collapses_duplicate_marker_blocks(tmp_path: pathlib.Path) -> None: + """install() over a file with two marker blocks collapses to exactly one.""" + config = tmp_path / "config.toml" + hook = CodexHook(config_path=config) + block = hook._build_block() + # Seed the file with two copies of the marker block + config.write_text(f'model = "o4"\n\n{block}\n{block}') + assert config.read_text().count("# >>> libtmux-agent-state >>>") == 2 + + hook.install() + + text = config.read_text() + assert text.count("# >>> libtmux-agent-state >>>") == 1 + assert hook.status() == "installed" + assert 'model = "o4"' in text From d185a422f207202e3ddce3a1dc690ec721548416 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:35:17 -0500 Subject: [PATCH 185/223] Agents(test[signals]): Cover BEL-terminated OSC 3008 why: The OscSignal regex accepts both ST and BEL terminators but only the ST path had a working doctest example. what: - Add BEL-terminated doctest to OscSignal.feed showing b"\033]3008;state=idle\007" yields a Reading with state idle --- src/libtmux/experimental/agents/signals.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/libtmux/experimental/agents/signals.py b/src/libtmux/experimental/agents/signals.py index e3d2eb71a..d8421c981 100644 --- a/src/libtmux/experimental/agents/signals.py +++ b/src/libtmux/experimental/agents/signals.py @@ -165,12 +165,21 @@ def feed(self, pane_id: str, data: bytes) -> list[Reading]: Examples -------- + ST-terminated (``ESC \``) path: + >>> osc = OscSignal() >>> readings = osc.feed("%1", b"\033]3008;state=awaiting_input\033\\") >>> len(readings) 1 >>> readings[0].state.value 'awaiting_input' + + BEL-terminated (``\\007``) path: + + >>> osc2 = OscSignal() + >>> readings2 = osc2.feed("%2", b"\033]3008;state=idle\007") + >>> readings2[0].state.value + 'idle' """ buffer = self._buffers.get(pane_id, b"") + data readings: list[Reading] = [] From 1f3b84e18dbabf4376e23a014ddb8212aa486d26 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:36:41 -0500 Subject: [PATCH 186/223] Agents(test[hooks]): Cover Codex outdated status why: The status() == "outdated" branch (marker present, content stale) had no test coverage. what: - Add test that installs, mutates the emit command in the block, and asserts status() returns "outdated" --- tests/experimental/agents/hooks/test_codex.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/experimental/agents/hooks/test_codex.py b/tests/experimental/agents/hooks/test_codex.py index 3362697ce..ff8861bef 100644 --- a/tests/experimental/agents/hooks/test_codex.py +++ b/tests/experimental/agents/hooks/test_codex.py @@ -65,3 +65,21 @@ def test_install_collapses_duplicate_marker_blocks(tmp_path: pathlib.Path) -> No assert text.count("# >>> libtmux-agent-state >>>") == 1 assert hook.status() == "installed" assert 'model = "o4"' in text + + +def test_status_outdated_when_block_is_stale(tmp_path: pathlib.Path) -> None: + """status() returns 'outdated' when marker block is present but content differs.""" + config = tmp_path / "config.toml" + hook = CodexHook(config_path=config) + hook.install() + + # Mutate one of the emit commands inside the block to make it stale + text = config.read_text() + stale = text.replace( + 'command = "libtmux-agent-emit running"', + 'command = "libtmux-agent-emit STALE"', + ) + assert stale != text, "replacement must differ" + config.write_text(stale) + + assert hook.status() == "outdated" From a671c49e2492568e5cb7b270bf4fdfdf743ed290 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:38:09 -0500 Subject: [PATCH 187/223] Agents(refactor[signals]): Drop duplicate OptionSignal doctest why: The class docstring and parse() method docstring carried identical doctests; --doctest-modules ran both, which is pure noise. what: - Replace class-level Examples block with prose description - Keep the method-level doctest as the single runnable example --- src/libtmux/experimental/agents/signals.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/libtmux/experimental/agents/signals.py b/src/libtmux/experimental/agents/signals.py index d8421c981..2eea91cdd 100644 --- a/src/libtmux/experimental/agents/signals.py +++ b/src/libtmux/experimental/agents/signals.py @@ -85,14 +85,9 @@ def _parse_payload(payload: str) -> tuple[AgentState, str | None]: class OptionSignal: """Parse the local ``@agent_state`` subscription channel. - Examples - -------- - >>> r = OptionSignal.parse( - ... "%subscription-changed agentstate $0 @0 1 %3 : running") - >>> r.pane_id, r.state.value - ('%3', 'running') - >>> OptionSignal.parse("%output %1 hi") is None - True + Matches ``%subscription-changed`` notifications for the ``agentstate`` + subscription and extracts the pane and state. Non-matching lines are + silently dropped (``parse`` returns ``None``). """ @staticmethod From 0a6f53fbb2dd048da22182df6c69a822c6fea453 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:39:07 -0500 Subject: [PATCH 188/223] Agents(refactor[monitor]): Drop duplicate AgentStore import why: AgentStore was imported at runtime and again under TYPE_CHECKING; the runtime import already satisfies the annotation, so the second import is dead. what: - Remove the redundant TYPE_CHECKING import of AgentStore --- src/libtmux/experimental/agents/monitor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 9bc24cb18..f6658fbac 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -39,7 +39,6 @@ from libtmux.experimental.agents.merge import Clock from libtmux.experimental.agents.signals import Reading from libtmux.experimental.agents.state import Agent - from libtmux.experimental.agents.store import AgentStore from libtmux.experimental.models.snapshots import PaneSnapshot, ServerSnapshot logger = logging.getLogger(__name__) From f2021b7a6a9eaae278b5434eb77e797af5b12211 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:40:56 -0500 Subject: [PATCH 189/223] Agents(fix[monitor]): Skip attach when own-session probe fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: When the display-message own-id probe fails, own is None, so the `sid != own` guard is always true and _primary_session_id falls through to return ids[0] — tmux's phantom `tmux -C` session (it sorts first), which holds no agent panes. Attaching there leaves the option channel effectively silent. what: - Return None from _primary_session_id when the own-session probe fails, so start() skips attach instead of binding to the phantom session - Cover the case with a fake engine whose display-message probe raises --- src/libtmux/experimental/agents/monitor.py | 15 +++++++-- tests/experimental/agents/test_monitor.py | 38 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index f6658fbac..db1104588 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -302,17 +302,26 @@ async def _primary_session_id(self) -> str | None: session only when no other exists (an otherwise empty server). Defensive: any engine error (no daemon, list failure) is logged and - yields ``None`` so :meth:`start` can proceed without attaching. + yields ``None`` so :meth:`start` can proceed without attaching. The + own-session probe failing is treated the same way: without knowing which + session is the phantom, ``list-sessions[0]`` would be the phantom + ``tmux -C`` session (it sorts first), so this returns ``None`` and skips + attach rather than binding to a session that holds no agent panes. Returns ------- str | None - The session id to attach to, or ``None`` when the list is empty or - the engine call failed. + The session id to attach to, or ``None`` when the list is empty, the + engine call failed, or the own-session probe failed. """ from libtmux.experimental.engines.base import CommandRequest own = await self._own_session_id() + if own is None: + # Without the phantom's id, ids[0] would be tmux's own throwaway + # `tmux -C` session — attaching there delivers no real agent panes. + logger.debug("own-session probe failed — monitor will not attach") + return None try: result = await self._engine.run( CommandRequest.from_args("list-sessions", "-F", "#{session_id}") diff --git a/tests/experimental/agents/test_monitor.py b/tests/experimental/agents/test_monitor.py index 72b80b5bf..5b3b792d5 100644 --- a/tests/experimental/agents/test_monitor.py +++ b/tests/experimental/agents/test_monitor.py @@ -2,10 +2,12 @@ from __future__ import annotations +import asyncio import os from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.engines.base import CommandRequest, CommandResult from libtmux.experimental.models.snapshots import PaneSnapshot @@ -19,6 +21,42 @@ def add_subscription(self, spec: object) -> None: ... def set_attach_targets(self, ids: object) -> None: ... +class _ProbeFailEngine: + """An engine whose own-id (``display-message``) probe raises. + + ``list-sessions`` still succeeds and reports two sessions, so the only + reason ``_primary_session_id`` could return ``None`` is the failing + own-session probe (the case under test). + """ + + async def run(self, request: CommandRequest) -> CommandResult: + if request.args[:1] == ("display-message",): + msg = "display-message failed" + raise RuntimeError(msg) + return CommandResult(cmd=request.args, stdout=("$0", "$1")) + + async def subscribe(self) -> None: ... + + def add_subscription(self, spec: object) -> None: ... + + def set_attach_targets(self, ids: object) -> None: ... + + +def test_primary_session_id_none_when_own_probe_fails() -> None: + """A failing own-session probe skips attach (no phantom binding). + + Without the phantom's id, ``list-sessions[0]`` is tmux's own throwaway + ``tmux -C`` session, so the monitor must decline to attach rather than + bind to a session that holds no agent panes. + """ + + async def main() -> str | None: + mon = AgentMonitor(_ProbeFailEngine()) + return await mon._primary_session_id() + + assert asyncio.run(main()) is None + + def test_ingest_option_line_updates_agent() -> None: """Option-channel %subscription-changed maps to a store entry.""" mon = AgentMonitor(_FakeEngine()) From 72fe84ae2b9aefd5fce0d00b64d8587ef4c7e517 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:49:31 -0500 Subject: [PATCH 190/223] Agents(fix): Verify attach-session succeeded before marking attached why: tmux reports a failed attach (e.g. stale session id) as a non-zero returncode, not an exception, so the monitor recorded _attached_session even when the attach failed -- silencing the option channel. what: - monitor.start() now records the sticky attach only when attach-session returns returncode 0; logs the stderr on failure - document _replay_attach's optimistic fire-and-forget attach and that a failed re-attach self-corrects on the monitor's next reconcile --- src/libtmux/experimental/agents/monitor.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index db1104588..1abad6a59 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -273,13 +273,25 @@ async def start(self) -> None: if session_id is not None: self._engine.set_attach_targets([session_id]) try: - await self._engine.run( + result = await self._engine.run( CommandRequest.from_args("attach-session", "-t", session_id) ) - # Mirror the events layer: record the sticky attach so a later - # _ensure_attached (MCP) does not redundantly re-attach. - self._engine._attached_session = session_id - logger.debug("monitor attached session %s", session_id) + # A tmux-side failure (e.g. a stale session id) is *data* -- a + # non-zero returncode, not an exception -- so only record the + # sticky attach when the command actually succeeded. Recording a + # failed attach would point _attached_session at an unattached + # session and silence the option channel. + if result.returncode == 0: + # Mirror the events layer: record the sticky attach so a later + # _ensure_attached (MCP) does not redundantly re-attach. + self._engine._attached_session = session_id + logger.debug("monitor attached session %s", session_id) + else: + logger.debug( + "monitor attach-session failed for %s: %s", + session_id, + result.stderr, + ) except Exception: logger.debug("monitor attach-session failed", exc_info=True) From a37b69af5a412057ea6cf0710b9a4952a1a335f7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:47:01 -0500 Subject: [PATCH 191/223] Agents(feat[tree]): Read pane_floating_flag into snapshots why: The agent monitor needs to tell a floating overlay (e.g. a status HUD) apart from a real agent pane; the snapshot had no floating flag and the monitor's pane format did not request it. what: - Add PaneSnapshot.floating, parsed from #{pane_floating_flag} (tmux 3.7+; renders empty -> False on older tmux, so no version gate) - Request pane_floating_flag in the monitor's PANE_FORMAT - Cover the snapshot floating flag and the format request Foundation for the floating HUD; the renderer + monitor self-exclusion land next. --- src/libtmux/experimental/agents/tree.py | 1 + tests/experimental/agents/test_tree.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/agents/tree.py b/src/libtmux/experimental/agents/tree.py index 669505c80..fa7b51eaf 100644 --- a/src/libtmux/experimental/agents/tree.py +++ b/src/libtmux/experimental/agents/tree.py @@ -22,6 +22,7 @@ "pane_id", "pane_index", "pane_active", + "pane_floating_flag", "pane_pid", "pane_current_command", "pane_title", diff --git a/tests/experimental/agents/test_tree.py b/tests/experimental/agents/test_tree.py index 6dcc16507..0562607a4 100644 --- a/tests/experimental/agents/test_tree.py +++ b/tests/experimental/agents/test_tree.py @@ -2,10 +2,15 @@ from __future__ import annotations -from libtmux.experimental.agents.tree import diff_panes, panes_of +from libtmux.experimental.agents.tree import PANE_FORMAT, diff_panes, panes_of from libtmux.experimental.models.snapshots import ServerSnapshot +def test_pane_format_requests_floating_flag() -> None: + """The monitor's pane format requests pane_floating_flag (tmux 3.7 floats).""" + assert "pane_floating_flag" in PANE_FORMAT + + def _snap(pane_ids: list[str]) -> ServerSnapshot: rows = [ { From 5d4f875a3d74fe481464831525ff1798fdf837e3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 17:00:57 -0500 Subject: [PATCH 192/223] Agents(feat[hud]): Add floating agent-state HUD why: The agent monitor was observation-only; on tmux 3.7 a floating overlay can surface live agent state in the session itself, with no external UI. what: - Add HudRenderer: a pure AgentStore -> text frame plus the typed RespawnPane paint op (the frame is shell-quoted and held open with `tail -f /dev/null` so it persists between repaints) - AgentMonitor gains opt-in `hud=True`: start() creates one floating NewPane over the primary session and captures its id; the supervised drain repaints on every store change (dirty flag set in _observe and reconcile); stop() kills the HUD pane - Exclude the HUD's own pane from _reconcile_once so it never enters the diff or the health sweep (tracking is signal-driven, so this is the only exclusion needed); best-effort throughout (no session, engine error, or tmux < 3.7 silently skips the HUD) - Cover the renderer, the repaint op, HUD create/teardown, and the reconcile self-exclusion --- src/libtmux/experimental/agents/hud.py | 89 +++++++++++++++ src/libtmux/experimental/agents/monitor.py | 85 +++++++++++++- tests/experimental/agents/test_hud.py | 123 +++++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/libtmux/experimental/agents/hud.py create mode 100644 tests/experimental/agents/test_hud.py diff --git a/src/libtmux/experimental/agents/hud.py b/src/libtmux/experimental/agents/hud.py new file mode 100644 index 000000000..7a8024e2c --- /dev/null +++ b/src/libtmux/experimental/agents/hud.py @@ -0,0 +1,89 @@ +"""Render the agent store into a floating HUD pane (tmux 3.7+). + +A *pure* renderer: an :class:`~libtmux.experimental.agents.store.AgentStore` +becomes text, the text becomes a shell command that paints it into a pane, and +that command becomes a typed op. The :class:`~..monitor.AgentMonitor` drives it -- +it creates a floating pane on start, repaints on every store change, and tears it +down on stop. The renderer itself touches no tmux and no engine, so it is fully +unit-testable. + +The paint command writes the frame and then holds the pane open at zero CPU +(``tail -f /dev/null``) so the frame stays visible until the next repaint. +""" + +from __future__ import annotations + +import shlex +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops import RespawnPane +from libtmux.experimental.ops._types import PaneId + +if t.TYPE_CHECKING: + from libtmux.experimental.agents.store import AgentStore + +#: Liveness glyphs; the state name carries the detail. +_ALIVE = "●" # ● +_DEAD = "○" # ○ + + +def _hold_command(text: str) -> str: + """Build a shell command that paints *text* into a pane and holds it open. + + ``clear`` wipes the prior frame, ``printf`` writes the rendered text + (shell-quoted, so any content is safe), and ``exec tail -f /dev/null`` keeps + the pane alive at zero CPU so the frame persists until the next repaint. + """ + return f"clear; printf %s {shlex.quote(text)}; exec tail -f /dev/null" + + +@dataclass(frozen=True) +class HudRenderer: + """Render an :class:`~..store.AgentStore` into floating-HUD pane content.""" + + title: str = "agents" + + def render(self, store: AgentStore) -> str: + """Render the store to the HUD's text frame (one line per agent). + + Examples + -------- + >>> from libtmux.experimental.agents.store import AgentStore + >>> frame = HudRenderer().render(AgentStore()) + >>> frame.startswith("agents") and "(no agents)" in frame + True + """ + agents = sorted(store.agents.values(), key=lambda agent: agent.pane_id) + lines = [self.title, ""] + if not agents: + lines.append("(no agents)") + for agent in agents: + mark = _ALIVE if agent.alive else _DEAD + name = agent.name or "" + lines.append( + f"{mark} {agent.state.value:<14} {agent.pane_id} {name}".rstrip() + ) + return "\n".join(lines) + "\n" + + def paint_command(self, store: AgentStore) -> str: + """Return the shell command that paints the current store into a pane.""" + return _hold_command(self.render(store)) + + def repaint_op(self, hud_pane_id: str, store: AgentStore) -> RespawnPane: + """Build the typed op that repaints the HUD pane from the current store. + + Examples + -------- + >>> from libtmux.experimental.agents.store import AgentStore + >>> op = HudRenderer().repaint_op("%9", AgentStore()) + >>> op.command, op.kill + ('respawn-pane', True) + >>> op.render()[:3] + ('respawn-pane', '-t', '%9') + """ + return RespawnPane( + target=PaneId(hud_pane_id), + kill=True, + shell=self.paint_command(store), + ) diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 1abad6a59..9ac45b4a4 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -23,6 +23,7 @@ import typing as t from libtmux.experimental.agents.health import is_alive +from libtmux.experimental.agents.hud import HudRenderer from libtmux.experimental.agents.merge import MonotonicCounter, Stamp from libtmux.experimental.agents.signals import SUBSCRIPTION, OptionSignal, OscSignal from libtmux.experimental.agents.state import AgentState @@ -63,6 +64,10 @@ class AgentMonitor: clock : Clock or None Logical clock for stamping updates. Defaults to :class:`~libtmux.experimental.agents.merge.MonotonicCounter`. + hud : bool + Show a floating HUD pane (tmux 3.7+) that repaints the agent store on + every change. Off by default; the HUD pane is excluded from agent + tracking and torn down on :meth:`stop`. Examples -------- @@ -83,6 +88,7 @@ def __init__( *, sink: Storage | None = None, clock: Clock | None = None, + hud: bool = False, ) -> None: self._engine = engine self._sink = sink @@ -92,6 +98,13 @@ def __init__( self._task: asyncio.Task[None] | None = None # Supervised-drain control: stop() flips this; _run() exits its loop. self._stopping = False + # Optional floating HUD (tmux 3.7+): a single floating pane that repaints + # the agent store on every change. Opt-in so existing callers (and older + # tmux) are unaffected; the pane is excluded from agent tracking. + self._hud_enabled = hud + self._hud_renderer = HudRenderer() + self._hud_pane_id: str | None = None + self._hud_dirty = False # Bounded poll between reconcile retries while the engine is reconnecting, # so the supervised drain waits (not busy-spins) for the engine to revive. self._reconnect_poll = 0.5 @@ -173,6 +186,7 @@ def _observe(self, reading: Reading) -> None: pid=None, ) self._store = apply(self._store, observed, now=time.monotonic()) + self._hud_dirty = True if self._sink is not None: self._sink.save(self._store.to_dict()) logger.debug( @@ -299,6 +313,8 @@ async def start(self) -> None: # then hand off to the supervised drain for the engine's whole lifetime. self._stopping = False await self.reconcile() + if self._hud_enabled: + await self._ensure_hud() self._task = asyncio.get_running_loop().create_task(self._run()) async def _primary_session_id(self) -> str | None: @@ -398,6 +414,7 @@ async def stop(self) -> None: with contextlib.suppress(asyncio.CancelledError): await self._task self._task = None + await self._teardown_hud() if self._sink is not None: self._sink.save(self._store.to_dict()) @@ -430,7 +447,13 @@ async def _reconcile_once(self) -> None: result = await self._engine.run(req) rows = _parse_pane_rows(result.stdout) snapshot: ServerSnapshot = ServerSnapshot.from_pane_rows(rows) - current_panes = panes_of(snapshot) + # The monitor's own floating HUD is not an agent pane: keep it out of the + # tracked set so it never enters the diff or the health sweep. + current_panes = { + pane_id: pane + for pane_id, pane in panes_of(snapshot).items() + if pane_id != self._hud_pane_id + } _added, removed = diff_panes(self._prev_panes, current_panes) for pane_id in removed: self._store = apply( @@ -440,6 +463,7 @@ async def _reconcile_once(self) -> None: ) self._apply_health(current_panes) self._prev_panes = dict(current_panes) + self._hud_dirty = True def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: """Refresh tracked agents' ``pid``/``alive`` from the pane tree. @@ -492,6 +516,63 @@ def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: # Private helpers # ------------------------------------------------------------------ + async def _ensure_hud(self) -> None: + """Create the floating HUD pane over the primary session and paint it. + + Best-effort: with no session to host it, an engine error, or a server + older than tmux 3.7 (``new-pane`` is unknown there), the HUD is silently + skipped and the monitor runs without it. + """ + from libtmux.experimental.ops import NewPane, arun + from libtmux.experimental.ops._types import SessionId + + session_id = await self._primary_session_id() + if session_id is None: + logger.debug("no session to host the agent HUD — skipping") + return + op = NewPane( + target=SessionId(session_id), + detach=True, + width="40%", + height="40%", + shell_command=self._hud_renderer.paint_command(self._store), + ) + try: + result = await arun(op, self._engine) + except Exception: + logger.debug("agent HUD creation failed", exc_info=True) + return + if not result.ok or result.new_pane_id is None: + logger.debug("agent HUD unavailable (new-pane failed)") + return + self._hud_pane_id = result.new_pane_id + self._hud_dirty = False + logger.debug("agent HUD created on pane %s", self._hud_pane_id) + + async def _repaint_hud(self) -> None: + """Repaint the HUD pane from the current store when it has changed.""" + if self._hud_pane_id is None or not self._hud_dirty: + return + from libtmux.experimental.ops import arun + + self._hud_dirty = False + op = self._hud_renderer.repaint_op(self._hud_pane_id, self._store) + try: + await arun(op, self._engine) + except Exception: + logger.debug("agent HUD repaint failed", exc_info=True) + + async def _teardown_hud(self) -> None: + """Kill the floating HUD pane if one was created.""" + if self._hud_pane_id is None: + return + from libtmux.experimental.ops import KillPane, arun + from libtmux.experimental.ops._types import PaneId + + hud_pane_id, self._hud_pane_id = self._hud_pane_id, None + with contextlib.suppress(Exception): + await arun(KillPane(target=PaneId(hud_pane_id)), self._engine) + async def _run(self) -> None: """Supervised drain: re-subscribe + reconcile across engine reconnects. @@ -511,10 +592,12 @@ async def _run(self) -> None: logger.debug("agents: reconcile not ready, retrying", exc_info=True) await asyncio.sleep(self._reconnect_poll) continue + await self._repaint_hud() try: async with contextlib.aclosing(self._engine.subscribe()) as stream: async for note in stream: self.ingest(note.raw) + await self._repaint_hud() except asyncio.CancelledError: raise except Exception: diff --git a/tests/experimental/agents/test_hud.py b/tests/experimental/agents/test_hud.py new file mode 100644 index 000000000..daa598b9a --- /dev/null +++ b/tests/experimental/agents/test_hud.py @@ -0,0 +1,123 @@ +"""Tests for the floating agent HUD (renderer + monitor lifecycle).""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.agents.hud import HudRenderer +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import Agent, AgentState +from libtmux.experimental.agents.store import AgentStore + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import CommandRequest + + +def _agent(pane_id: str, *, state: AgentState, alive: bool, name: str) -> Agent: + return Agent( + pane_id=pane_id, + key=pane_id, + name=name, + state=state, + since=0.0, + source="osc", + pid=1, + alive=alive, + ) + + +def test_render_empty_store() -> None: + """An empty store renders the header and a placeholder.""" + frame = HudRenderer().render(AgentStore()) + assert frame.startswith("agents") + assert "(no agents)" in frame + + +def test_render_lists_agents() -> None: + """Each agent renders with its state, pane id, name, and a liveness glyph.""" + store = AgentStore( + agents={ + "%1": _agent("%1", state=AgentState.RUNNING, alive=True, name="claude"), + "%2": _agent("%2", state=AgentState.EXITED, alive=False, name="codex"), + }, + ) + frame = HudRenderer().render(store) + assert "running" in frame + assert "%1" in frame + assert "claude" in frame + assert "●" in frame # alive glyph + assert "○" in frame # dead glyph (the exited agent) + + +def test_repaint_op_targets_pane() -> None: + """repaint_op builds a respawn-pane op carrying the rendered frame.""" + store = AgentStore( + agents={"%1": _agent("%1", state=AgentState.IDLE, alive=True, name="claude")}, + ) + op = HudRenderer().repaint_op("%9", store) + assert op.command == "respawn-pane" + assert op.kill is True + assert op.render()[:3] == ("respawn-pane", "-t", "%9") + assert "claude" in (op.shell or "") # the rendered frame is embedded + + +class _HudEngine: + """A minimal async engine that satisfies the HUD lifecycle calls.""" + + def __init__(self, pane_rows: tuple[str, ...] = ()) -> None: + self._pane_rows = pane_rows + self.killed: list[str] = [] + + async def run(self, request: CommandRequest) -> t.Any: + from libtmux.experimental.engines.base import CommandResult + + cmd = request.args[0] + if cmd == "display-message": + return CommandResult(cmd=request.args, stdout=("$0",)) + if cmd == "list-sessions": + return CommandResult(cmd=request.args, stdout=("$0", "$1")) + if cmd == "list-panes": + return CommandResult(cmd=request.args, stdout=self._pane_rows) + if cmd == "new-pane": + return CommandResult(cmd=request.args, stdout=("%99",)) + if cmd == "kill-pane": + self.killed.append(request.args[-1]) + return CommandResult(cmd=request.args, returncode=0) + return CommandResult(cmd=request.args, returncode=0) + + +def test_ensure_and_teardown_hud() -> None: + """The monitor creates a floating HUD pane and kills it on teardown.""" + engine = _HudEngine() + monitor = AgentMonitor(engine, hud=True) + + async def go() -> tuple[str | None, str | None]: + await monitor._ensure_hud() + created = monitor._hud_pane_id + await monitor._teardown_hud() + return created, monitor._hud_pane_id + + created, after = asyncio.run(go()) + assert created == "%99" + assert after is None + assert "%99" in engine.killed + + +def _pane_row(pane_id: str) -> str: + """Return a tab-joined list-panes row with *pane_id* in its slot.""" + return "\t".join( + ["$0", "s", "@0", "0", "w", "1", pane_id, "0", "1", "0", "", "", ""], + ) + + +def test_reconcile_excludes_hud_pane() -> None: + """The HUD's own pane is kept out of the tracked pane set.""" + engine = _HudEngine(pane_rows=(_pane_row("%1"), _pane_row("%99"))) + monitor = AgentMonitor(engine) + monitor._hud_pane_id = "%99" + + asyncio.run(monitor._reconcile_once()) + + assert "%1" in monitor._prev_panes + assert "%99" not in monitor._prev_panes From ac220a5b2c7ac5ecf18f55da4eb986f99c642398 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:34:15 -0500 Subject: [PATCH 193/223] Mcp(fix[agents]): watch_agents observes the store why: watch_agents opened its own engine.subscribe() and re-ingested the fan-out stream while the monitor's drain already ingests it, so every event was processed twice -- drifting the MonotonicCounter and `since` stamps. It was also tagged readOnlyHint=True despite mutating the store. what: - Drop the redundant subscribe()+ingest loop; observe the monitor's live store over the window (the monitor's drain is the sole ingester), so readOnlyHint=True is now accurate - Cover that watch_agents never calls engine.subscribe() --- .../experimental/mcp/vocabulary/agents.py | 20 +++----- tests/experimental/mcp/test_agents_tools.py | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/libtmux/experimental/mcp/vocabulary/agents.py b/src/libtmux/experimental/mcp/vocabulary/agents.py index 43bce28b7..4e08abf0e 100644 --- a/src/libtmux/experimental/mcp/vocabulary/agents.py +++ b/src/libtmux/experimental/mcp/vocabulary/agents.py @@ -22,7 +22,6 @@ from __future__ import annotations import asyncio -import contextlib import typing as t if t.TYPE_CHECKING: @@ -167,14 +166,15 @@ async def list_agents() -> list[dict[str, t.Any]]: async def watch_agents(timeout_s: float = 5.0) -> dict[str, t.Any]: """Collect agent-state transitions for up to *timeout_s* seconds. - Drains the engine's ``subscribe()`` stream through - :meth:`~libtmux.experimental.agents.monitor.AgentMonitor.ingest` - and returns any state changes observed within the window. + Observes the monitor's live store over the window and returns any state + changes. The monitor's own drain task is the sole ingester, so this only + reads ``monitor.agents`` (no second subscription, no re-ingest) -- keeping + the clock and ``since`` stamps accurate. Parameters ---------- timeout_s : float - Wall-clock seconds to collect before returning (default 5.0). + Wall-clock seconds to observe before returning (default 5.0). Returns ------- @@ -183,15 +183,7 @@ async def watch_agents(timeout_s: float = 5.0) -> dict[str, t.Any]: for agents whose state changed; ``count`` -- number of transitions. """ snapshot_before = {a.pane_id: a.state.value for a in monitor.agents} - - async def _collect() -> None: - async with contextlib.aclosing(engine.subscribe()) as stream: - async for note in stream: - monitor.ingest(note.raw) - - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(_collect(), timeout=timeout_s) - + await asyncio.sleep(timeout_s) snapshot_after = {a.pane_id: a.state.value for a in monitor.agents} transitions = [ { diff --git a/tests/experimental/mcp/test_agents_tools.py b/tests/experimental/mcp/test_agents_tools.py index 93c480b17..52e7bfce4 100644 --- a/tests/experimental/mcp/test_agents_tools.py +++ b/tests/experimental/mcp/test_agents_tools.py @@ -2,8 +2,11 @@ from __future__ import annotations +import asyncio import typing as t +import pytest + from libtmux.experimental.agents.monitor import AgentMonitor @@ -18,9 +21,53 @@ def add_subscription(self, spec: object) -> None: ... def set_attach_targets(self, ids: object) -> None: ... +class _CountingEngine(_FakeEngine): + """A fake engine that records how many times ``subscribe()`` is called.""" + + def __init__(self) -> None: + self.subscribe_calls = 0 + + async def subscribe(self) -> t.AsyncIterator[object]: + self.subscribe_calls += 1 + return + yield + + +class _CapturingMcp: + """A FastMCP stand-in that captures registered tools by name.""" + + def __init__(self) -> None: + self.tools: dict[str, t.Any] = {} + + def add_tool(self, tool: t.Any) -> None: + self.tools[tool.name] = tool + + def test_list_agents_reflects_ingested_state() -> None: """list_agents shape: ingested option-line produces the expected pane dict.""" mon = AgentMonitor(_FakeEngine()) mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") listing = [{"pane_id": a.pane_id, "state": a.state.value} for a in mon.agents] assert {"pane_id": "%1", "state": "running"} in listing + + +def test_watch_agents_observes_store_without_subscribing() -> None: + """watch_agents reads the monitor's store; it opens no second subscription. + + The monitor's own drain is the sole ingester, so watch_agents must only read + ``monitor.agents`` -- a second ``subscribe()`` would double-ingest and drift + the clock. + """ + pytest.importorskip("fastmcp") + from libtmux.experimental.mcp.vocabulary.agents import register_agents + + engine = _CountingEngine() + mcp = _CapturingMcp() + monitor = register_agents(mcp, engine) + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + + watch = mcp.tools["watch_agents"].fn + result = asyncio.run(watch(timeout_s=0.01)) + + assert engine.subscribe_calls == 0 # fix: watch_agents does not re-subscribe + assert result == {"transitions": [], "count": 0} # static store over the window From f82aa66c431fedfaddb997d5b490b40f2b56863d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:39:07 -0500 Subject: [PATCH 194/223] Agents(fix[monitor]): Recover the floating HUD after a restart why: _ensure_hud ran only in start(). After a full tmux restart the HUD pane id is dead; _repaint_hud ignored the arun result (which reports a tmux-side failure as data, not a raise), so _hud_pane_id was never cleared and the HUD stayed dark for the rest of the session. what: - _repaint_hud now checks the result: a failed/errored repaint drops _hud_pane_id (leaving it dirty) so it can be recreated; the dirty flag is cleared only on a successful paint - _run recreates the HUD (_ensure_hud) after a reconcile when it is enabled but has no pane id -- covering both a restart and an initial create that had no session yet - Cover ok-keeps-pane vs fail-drops-pane (parametrized) --- src/libtmux/experimental/agents/monitor.py | 20 ++++++-- tests/experimental/agents/test_hud.py | 56 +++++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 9ac45b4a4..bda087428 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -550,17 +550,29 @@ async def _ensure_hud(self) -> None: logger.debug("agent HUD created on pane %s", self._hud_pane_id) async def _repaint_hud(self) -> None: - """Repaint the HUD pane from the current store when it has changed.""" + """Repaint the HUD pane from the current store when it has changed. + + A failed repaint -- the HUD pane vanished (e.g. a full tmux restart) -- + drops :attr:`_hud_pane_id` so :meth:`_run` recreates the HUD on its next + pass; the dirty flag stays set so the fresh pane shows the current state. + """ if self._hud_pane_id is None or not self._hud_dirty: return from libtmux.experimental.ops import arun - self._hud_dirty = False op = self._hud_renderer.repaint_op(self._hud_pane_id, self._store) try: - await arun(op, self._engine) + result = await arun(op, self._engine) except Exception: logger.debug("agent HUD repaint failed", exc_info=True) + self._hud_pane_id = None + return + if not result.ok: + # arun returns failure as data (no raise); a dead pane id means the + # HUD is gone, so drop it for _run to recreate. + self._hud_pane_id = None + return + self._hud_dirty = False async def _teardown_hud(self) -> None: """Kill the floating HUD pane if one was created.""" @@ -592,6 +604,8 @@ async def _run(self) -> None: logger.debug("agents: reconcile not ready, retrying", exc_info=True) await asyncio.sleep(self._reconnect_poll) continue + if self._hud_enabled and self._hud_pane_id is None: + await self._ensure_hud() # (re)create after a restart dropped it await self._repaint_hud() try: async with contextlib.aclosing(self._engine.subscribe()) as stream: diff --git a/tests/experimental/agents/test_hud.py b/tests/experimental/agents/test_hud.py index daa598b9a..b034fc14d 100644 --- a/tests/experimental/agents/test_hud.py +++ b/tests/experimental/agents/test_hud.py @@ -5,6 +5,8 @@ import asyncio import typing as t +import pytest + from libtmux.experimental.agents.hud import HudRenderer from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import Agent, AgentState @@ -65,8 +67,14 @@ def test_repaint_op_targets_pane() -> None: class _HudEngine: """A minimal async engine that satisfies the HUD lifecycle calls.""" - def __init__(self, pane_rows: tuple[str, ...] = ()) -> None: + def __init__( + self, + pane_rows: tuple[str, ...] = (), + *, + respawn_returncode: int = 0, + ) -> None: self._pane_rows = pane_rows + self._respawn_returncode = respawn_returncode self.killed: list[str] = [] async def run(self, request: CommandRequest) -> t.Any: @@ -81,6 +89,12 @@ async def run(self, request: CommandRequest) -> t.Any: return CommandResult(cmd=request.args, stdout=self._pane_rows) if cmd == "new-pane": return CommandResult(cmd=request.args, stdout=("%99",)) + if cmd == "respawn-pane": + return CommandResult( + cmd=request.args, + stderr=() if self._respawn_returncode == 0 else ("no such pane",), + returncode=self._respawn_returncode, + ) if cmd == "kill-pane": self.killed.append(request.args[-1]) return CommandResult(cmd=request.args, returncode=0) @@ -121,3 +135,43 @@ def test_reconcile_excludes_hud_pane() -> None: assert "%1" in monitor._prev_panes assert "%99" not in monitor._prev_panes + + +class _RepaintCase(t.NamedTuple): + """A HUD repaint outcome and whether the pane id should survive it.""" + + test_id: str + repaint_ok: bool + expect_pane_kept: bool + + +_REPAINT_CASES = ( + _RepaintCase("ok_keeps_pane", repaint_ok=True, expect_pane_kept=True), + _RepaintCase("fail_drops_pane", repaint_ok=False, expect_pane_kept=False), +) + + +@pytest.mark.parametrize( + list(_RepaintCase._fields), + _REPAINT_CASES, + ids=[c.test_id for c in _REPAINT_CASES], +) +def test_repaint_drops_dead_pane( + test_id: str, + repaint_ok: bool, + expect_pane_kept: bool, +) -> None: + """A failed repaint drops _hud_pane_id so _run recreates the HUD.""" + engine = _HudEngine(respawn_returncode=0 if repaint_ok else 1) + monitor = AgentMonitor(engine, hud=True) + + async def go() -> str | None: + await monitor._ensure_hud() + assert monitor._hud_pane_id == "%99" + monitor._hud_dirty = True # force a repaint + await monitor._repaint_hud() + return monitor._hud_pane_id + + pane_after = asyncio.run(go()) + assert (pane_after == "%99") is expect_pane_kept + assert (pane_after is None) is not expect_pane_kept From 9a3cc3df5cdc1231cdcf64aac4391464aba41090 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:41:54 -0500 Subject: [PATCH 195/223] Agents(docs[hud]): Correct the repaint-cadence docstring why: The module docstring said the HUD "repaints on every store change", but _hud_dirty is set unconditionally on each notification and reconcile, so it repaints on those events rather than only on an actual mutation. what: - Reword to "repaints after each notification and reconcile" --- src/libtmux/experimental/agents/hud.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/agents/hud.py b/src/libtmux/experimental/agents/hud.py index 7a8024e2c..1e3e00a5a 100644 --- a/src/libtmux/experimental/agents/hud.py +++ b/src/libtmux/experimental/agents/hud.py @@ -3,9 +3,9 @@ A *pure* renderer: an :class:`~libtmux.experimental.agents.store.AgentStore` becomes text, the text becomes a shell command that paints it into a pane, and that command becomes a typed op. The :class:`~..monitor.AgentMonitor` drives it -- -it creates a floating pane on start, repaints on every store change, and tears it -down on stop. The renderer itself touches no tmux and no engine, so it is fully -unit-testable. +it creates a floating pane on start, repaints after each notification and +reconcile, and tears it down on stop. The renderer itself touches no tmux and no +engine, so it is fully unit-testable. The paint command writes the frame and then holds the pane open at zero CPU (``tail -f /dev/null``) so the frame stays visible until the next repaint. From 3cd1b2539a2a45c1e9cec693e84e7aed8d454d0b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:47:18 -0500 Subject: [PATCH 196/223] Agents(fix[hooks]): Isolate the install/uninstall doctests why: The AgentHook.install/uninstall doctests ran a bare ClaudeCodeHook(), which defaults settings_path to the real ~/.claude/settings.json and rewrites it on every doctest run. The "no-op on stub" comment was wrong -- these methods always write. what: - Redirect each doctest into a tempfile.TemporaryDirectory(), mirroring the already-isolated claude.py/codex.py doctests --- src/libtmux/experimental/agents/hooks/base.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/agents/hooks/base.py b/src/libtmux/experimental/agents/hooks/base.py index cf973ee11..92c8b6f8b 100644 --- a/src/libtmux/experimental/agents/hooks/base.py +++ b/src/libtmux/experimental/agents/hooks/base.py @@ -71,8 +71,11 @@ def install(self) -> None: Examples -------- + >>> import pathlib, tempfile >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook - >>> ClaudeCodeHook().install() # no-op on stub + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... hook.install() """ ... @@ -81,8 +84,11 @@ def uninstall(self) -> None: Examples -------- + >>> import pathlib, tempfile >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook - >>> ClaudeCodeHook().uninstall() # no-op on stub + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... hook.uninstall() """ ... From 24f1f6970b6da26bd15b78f3379adaaebacce6e0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:49:58 -0500 Subject: [PATCH 197/223] docs(CHANGES): Correct the reconcile cadence wording why: The agent-monitor entry described the liveness/reconcile sweep as "a periodic health probe" running "every few seconds". It is actually event/reconnect-driven: _run reconciles at startup and again each time the subscribe stream ends (a disconnect/reconnect). The only sleep is a retry backoff on the failure path, not a timer. what: - "refreshed by a periodic health probe" -> "refreshed on each reconciliation" - "runs every few seconds" -> "runs at startup and on each engine reconnect" --- CHANGES | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 17885ff61..f83bd22c7 100644 --- a/CHANGES +++ b/CHANGES @@ -57,13 +57,14 @@ control-mode engine and coalesces incoming tmux notifications into per-pane {class}`~libtmux.experimental.agents.state.Agent` records carrying the agent's name, its current {class}`~libtmux.experimental.agents.state.AgentState` (`RUNNING`, `AWAITING_INPUT`, `IDLE`, `EXITED`, or `UNKNOWN`), the timestamp of -the last transition, and a liveness flag refreshed by a periodic health probe. +the last transition, and a liveness flag refreshed on each reconciliation. Agents publish their state through tmux option subscriptions or OSC escape sequences; when both signals arrive for the same pane the monitor applies a last-writer-wins merge so the in-memory store stays consistent without locks. -A reconciliation sweep runs every few seconds to catch any pane the stream -missed, compare it against the stored snapshot, and emit the minimal diff — +A reconciliation sweep runs at startup and on each engine reconnect to catch +any pane the stream missed, compare it against the stored snapshot, and emit +the minimal diff — so the monitor self-heals across reconnects and supervisor restarts. Shell hooks for Claude Code and Codex are included; install them into a running session at any time via `install_agent_hooks` or the bundled From bd537ed2e68166a7398853e9c1803045c905690a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:57:56 -0500 Subject: [PATCH 198/223] Mcp(test): Type monitor test fakes for strict mypy why: Two monitor test fakes failed strict mypy: a stream-engine fake missing the _StreamEngine Protocol's _attached_session member, and a capturing FastMCP stand-in passed where FastMCP is expected. what: - Add _attached_session to FakeStreamEngine/_BlockingStreamEngine - Cast the capturing MCP fake to FastMCP at the register_agents call --- tests/experimental/mcp/test_agents_tools.py | 5 ++++- tests/experimental/mcp/test_events.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/experimental/mcp/test_agents_tools.py b/tests/experimental/mcp/test_agents_tools.py index 52e7bfce4..a00e13dfc 100644 --- a/tests/experimental/mcp/test_agents_tools.py +++ b/tests/experimental/mcp/test_agents_tools.py @@ -9,6 +9,9 @@ from libtmux.experimental.agents.monitor import AgentMonitor +if t.TYPE_CHECKING: + from fastmcp import FastMCP + class _FakeEngine: async def run(self, request: object) -> None: ... @@ -63,7 +66,7 @@ def test_watch_agents_observes_store_without_subscribing() -> None: engine = _CountingEngine() mcp = _CapturingMcp() - monitor = register_agents(mcp, engine) + monitor = register_agents(t.cast("FastMCP[t.Any]", mcp), engine) monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") watch = mcp.tools["watch_agents"].fn diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 7b95ad244..089b8737e 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -26,6 +26,8 @@ class FakeStreamEngine: """An async engine that replays a fixed notification stream.""" + _attached_session: str | None = None + def __init__(self, raw: tuple[bytes, ...]) -> None: self._raw = raw From 9116ac8293d6c3c11a833ba368c49d047a097ecb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:03:09 -0500 Subject: [PATCH 199/223] Agents(fix[store]): Tolerate unknown saved state why: from_dict raised ValueError on a state string absent from the current enum (e.g. a store written by a newer version), crashing the monitor on startup when it loads the store. what: - Deserialize state via AgentState.from_signal (unknown -> UNKNOWN), mirroring signal ingestion - Add parametrized tests for known/unknown/garbage states --- src/libtmux/experimental/agents/store.py | 2 +- tests/experimental/agents/test_store.py | 44 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/agents/store.py b/src/libtmux/experimental/agents/store.py index 50e71ae2f..992a4e3e8 100644 --- a/src/libtmux/experimental/agents/store.py +++ b/src/libtmux/experimental/agents/store.py @@ -163,7 +163,7 @@ def from_dict(cls, data: Mapping[str, t.Any]) -> AgentStore: pane_id=a["pane_id"], key=a["key"], name=a["name"], - state=AgentState(a["state"]), + state=AgentState.from_signal(a["state"]), since=a["since"], source=a["source"], pid=a["pid"], diff --git a/tests/experimental/agents/test_store.py b/tests/experimental/agents/test_store.py index 2d4590ed6..72fde5606 100644 --- a/tests/experimental/agents/test_store.py +++ b/tests/experimental/agents/test_store.py @@ -4,6 +4,9 @@ import json import pathlib +import typing as t + +import pytest from libtmux.experimental.agents.merge import Stamp from libtmux.experimental.agents.state import AgentState @@ -69,3 +72,44 @@ def test_jsonfile_atomic_roundtrip(tmp_path: pathlib.Path) -> None: assert restored.agents["%1"].state is AgentState.RUNNING # the saved file is valid JSON assert json.loads((tmp_path / "agents.json").read_text())["agents"] + + +class StateCase(t.NamedTuple): + """A persisted ``state`` string and the AgentState from_dict should yield.""" + + test_id: str + stored: str + expected: AgentState + + +STATE_CASES = ( + StateCase("known_round_trips", "running", AgentState.RUNNING), + StateCase("unknown_future_state", "paused", AgentState.UNKNOWN), + StateCase("garbage", "???", AgentState.UNKNOWN), +) + + +@pytest.mark.parametrize("case", STATE_CASES, ids=[c.test_id for c in STATE_CASES]) +def test_from_dict_tolerates_unknown_state(case: StateCase) -> None: + """from_dict round-trips known states and degrades unknown ones to UNKNOWN. + + A store written by a newer version (a state the current enum lacks) must not + crash the monitor on startup, so deserialization mirrors signal ingestion. + """ + data = { + "agents": { + "%1": { + "pane_id": "%1", + "key": "%1", + "name": "claude", + "state": case.stored, + "since": 1.0, + "source": "option", + "pid": 42, + "alive": True, + }, + }, + "stamps": {"%1": [1, "option"]}, + } + store = AgentStore.from_dict(data) + assert store.agents["%1"].state is case.expected From 4dca24744c011d6d450d47420a42e179375d783f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:37:49 -0500 Subject: [PATCH 200/223] Agents(fix[emit]): Guard --name with no value why: `emit ... --name` with the flag as the final CLI arg raised IndexError instead of a clean exit, surfacing a traceback to the agent's hook runner. what: - Fall back to name=None when --name has no following value - Add parametrized tests for main's --name parsing --- src/libtmux/experimental/agents/hooks/emit.py | 2 +- tests/experimental/agents/hooks/test_emit.py | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/agents/hooks/emit.py b/src/libtmux/experimental/agents/hooks/emit.py index 0e0a6be65..e4ce0e5d8 100644 --- a/src/libtmux/experimental/agents/hooks/emit.py +++ b/src/libtmux/experimental/agents/hooks/emit.py @@ -101,6 +101,6 @@ def main(argv: Sequence[str] | None = None) -> int: name: str | None = None if "--name" in args: idx = args.index("--name") - name = args[idx + 1] + name = args[idx + 1] if idx + 1 < len(args) else None emit(state, name=name) return 0 diff --git a/tests/experimental/agents/hooks/test_emit.py b/tests/experimental/agents/hooks/test_emit.py index f8ac443a6..0eecf4302 100644 --- a/tests/experimental/agents/hooks/test_emit.py +++ b/tests/experimental/agents/hooks/test_emit.py @@ -3,8 +3,12 @@ from __future__ import annotations import pathlib +import typing as t -from libtmux.experimental.agents.hooks.emit import emit +import pytest + +from libtmux.experimental.agents.hooks import emit as emit_mod +from libtmux.experimental.agents.hooks.emit import emit, main def test_local_uses_set_option() -> None: @@ -26,3 +30,38 @@ def test_remote_writes_osc_to_tty(tmp_path: pathlib.Path) -> None: emit("idle", tty_path=str(tty), env={}) # no TMUX → remote path data = tty.read_bytes() assert b"\033]3008;state=idle\033\\" in data + + +class MainNameCase(t.NamedTuple): + """A ``main`` argv and the ``name`` that should reach ``emit``.""" + + test_id: str + argv: list[str] + expected_name: str | None + + +MAIN_NAME_CASES = ( + MainNameCase("name_with_value", ["running", "--name", "claude"], "claude"), + MainNameCase("name_flag_last_no_value", ["running", "--name"], None), + MainNameCase("no_name_flag", ["running"], None), +) + + +@pytest.mark.parametrize( + "case", + MAIN_NAME_CASES, + ids=[c.test_id for c in MAIN_NAME_CASES], +) +def test_main_parses_name_flag( + case: MainNameCase, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``--name`` as the final arg yields name=None instead of an IndexError.""" + captured: dict[str, str | None] = {} + + def _fake_emit(state: str, *, name: str | None = None) -> None: + captured["name"] = name + + monkeypatch.setattr(emit_mod, "emit", _fake_emit) + assert main(case.argv) == 0 + assert captured["name"] == case.expected_name From acb6f087ebdb98941a0154bde09fda3fef39ee3d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:42:00 -0500 Subject: [PATCH 201/223] Mcp(fix[agents]): Offload hook I/O off event loop why: install_agent_hooks awaited blocking file I/O (read, fsync, atomic replace) directly on the event loop, stalling concurrent MCP tools during an install. what: - Run hook install/status via asyncio.to_thread - Add parametrized tests for the install tool (known/unknown agent) --- .../experimental/mcp/vocabulary/agents.py | 11 +++- tests/experimental/mcp/test_agents_tools.py | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/mcp/vocabulary/agents.py b/src/libtmux/experimental/mcp/vocabulary/agents.py index 4e08abf0e..2852f6a29 100644 --- a/src/libtmux/experimental/mcp/vocabulary/agents.py +++ b/src/libtmux/experimental/mcp/vocabulary/agents.py @@ -238,8 +238,15 @@ async def install_agent_hooks(agent: str) -> dict[str, t.Any]: hook = get(agent) except KeyError: return {"agent": agent, "error": "unknown agent"} - hook.install() - return {"agent": agent, "status": hook.status()} + + def _install_and_status() -> str: + hook.install() + return hook.status() + + # install/status do blocking file I/O (read, fsync, atomic replace); + # run off the event loop so concurrent MCP tools are not stalled. + status = await asyncio.to_thread(_install_and_status) + return {"agent": agent, "status": status} mcp.add_tool( FunctionTool.from_function( diff --git a/tests/experimental/mcp/test_agents_tools.py b/tests/experimental/mcp/test_agents_tools.py index a00e13dfc..6f9369a52 100644 --- a/tests/experimental/mcp/test_agents_tools.py +++ b/tests/experimental/mcp/test_agents_tools.py @@ -74,3 +74,54 @@ def test_watch_agents_observes_store_without_subscribing() -> None: assert engine.subscribe_calls == 0 # fix: watch_agents does not re-subscribe assert result == {"transitions": [], "count": 0} # static store over the window + + +class _InstallCase(t.NamedTuple): + """An install_agent_hooks scenario and the dict the tool should return.""" + + test_id: str + registered: bool + expected: dict[str, str] + + +_INSTALL_CASES = ( + _InstallCase("known_agent", True, {"agent": "claude", "status": "installed"}), + _InstallCase( + "unknown_agent", + False, + {"agent": "claude", "error": "unknown agent"}, + ), +) + + +@pytest.mark.parametrize( + "case", + _INSTALL_CASES, + ids=[c.test_id for c in _INSTALL_CASES], +) +def test_install_agent_hooks( + case: _InstallCase, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """install_agent_hooks returns the hook status, or an error for unknown agents.""" + pytest.importorskip("fastmcp") + from libtmux.experimental.agents.hooks import registry + from libtmux.experimental.mcp.vocabulary.agents import register_agents + + class _FakeHook: + def install(self) -> None: ... + + def status(self) -> str: + return "installed" + + def _get(name: str) -> _FakeHook: + if not case.registered: + raise KeyError(name) + return _FakeHook() + + monkeypatch.setattr(registry, "get", _get) + mcp = _CapturingMcp() + register_agents(t.cast("FastMCP[t.Any]", mcp), _FakeEngine()) + + install = mcp.tools["install_agent_hooks"].fn + assert asyncio.run(install("claude")) == case.expected From 409cbcac1eaf25a73bee7d552682b95749c49165 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:48:53 -0500 Subject: [PATCH 202/223] docs(CHANGES): Drop version from monitor entry why: A PR does not own its release version; the monitor entry named a concrete version that also went stale after rebasing onto newer master. what: - Open the entry with the package as subject, not "libtmux X.Y.Z ships" - Add the (#692) PR ref to the deliverable heading --- CHANGES | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index f83bd22c7..d98c77996 100644 --- a/CHANGES +++ b/CHANGES @@ -47,9 +47,9 @@ _Notes on the upcoming release will go here._ ### What's new -#### Agent-state monitor +#### Agent-state monitor (#692) -libtmux 0.59.x ships `libtmux.experimental.agents`, a live agent-state monitor +`libtmux.experimental.agents` is a live agent-state monitor that lets you see, at a glance, which coding agent in which pane is running, waiting for your input, or idle — all without polling or scraping pane output. A {class}`~libtmux.experimental.agents.monitor.AgentMonitor` subscribes to a From 31fe5fb6a8a7ecdf0f57103f1022448b628e44c5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 04:26:45 -0500 Subject: [PATCH 203/223] Agents(feat[sync]): Add wait/send/lock verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The monitor knew agent state but gave no way to act on it — no blocking wait for a state, no safe driver, and concurrent sends to one pane interleaved keystrokes. These are the fan-in/fan-out verbs an orchestrator needs, plus the correctness primitives single-operator tools never provide. what: - agents/wait.py: wait_for_agent_state + fleet wait_for_agents over a pure WaiterRegistry woken from the monitor's single _observe edge; outcomes as data (AgentWait/WaitReason); zero tmux calls (level check + future, never a poll). - agents/drive.py: send_to_agent/send_to_agents fold every multi-step and fleet send into ONE dispatch via LazyPlan + FoldingPlanner; a process-wide per-pane pane_lock chokepoint serializes all async keystroke injection; DedupLedger makes a keyed retry a no-op. - agents/state.py: AgentTransition value. - agents/monitor.py: host the registry/ledger; diff before->after in _observe/_apply_health/reconcile to wake waiters, emit a structured agent_* INFO log, and fan AgentTransition to observers; stop() resolves parked waits (STOPPED) so none hang. - mcp: wait_for_agent + send_to_agent tools; asend_input now takes the same pane_lock (the chokepoint retrofit). - Design spec + unit/live tests (folding asserted at one dispatch). --- .../2026-06-30-agents-sync-layer-design.md | 280 +++++++++++++ src/libtmux/experimental/agents/__init__.py | 39 +- src/libtmux/experimental/agents/drive.py | 272 +++++++++++++ src/libtmux/experimental/agents/monitor.py | 143 ++++++- src/libtmux/experimental/agents/state.py | 24 ++ src/libtmux/experimental/agents/wait.py | 382 ++++++++++++++++++ .../experimental/mcp/vocabulary/agents.py | 125 ++++++ .../experimental/mcp/vocabulary/pane.py | 37 +- tests/experimental/agents/test_drive.py | 137 +++++++ tests/experimental/agents/test_sync_live.py | 83 ++++ tests/experimental/agents/test_wait.py | 173 ++++++++ tests/experimental/mcp/test_agents_tools.py | 46 +++ 12 files changed, 1724 insertions(+), 17 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-30-agents-sync-layer-design.md create mode 100644 src/libtmux/experimental/agents/drive.py create mode 100644 src/libtmux/experimental/agents/wait.py create mode 100644 tests/experimental/agents/test_drive.py create mode 100644 tests/experimental/agents/test_sync_live.py create mode 100644 tests/experimental/agents/test_wait.py diff --git a/docs/superpowers/specs/2026-06-30-agents-sync-layer-design.md b/docs/superpowers/specs/2026-06-30-agents-sync-layer-design.md new file mode 100644 index 000000000..5739a9b62 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-agents-sync-layer-design.md @@ -0,0 +1,280 @@ +# Design: `libtmux.experimental.agents` sync layer — wait, drive, transitions + +- **Date:** 2026-06-30 +- **Branch:** `engine-ops-supatui` +- **Status:** Approved to build (user: "continue all the way through") +- **Topic:** the agent *synchronization* layer over the existing `AgentMonitor` — + block until an agent reaches a state, drive an agent safely, and surface the + state-transition edge. The fan-out/fan-in verbs an orchestrator calls. +- **Builds on:** `2026-06-26-agents-agent-state-monitor-design.md` (the monitor, + store, signals, reconcile loop). This spec adds verbs; it does not change the + monitor's source-of-truth model. + +--- + +## 1. Goal & motivation + +The monitor knows *what every agent is doing*. This layer adds the verbs an +orchestrator uses to **act on** that knowledge: + +- **`wait_for_agent_state`** — block until a pane's agent reaches a target state + (`AWAITING_INPUT`, `IDLE`, `EXITED`, …), with a timeout. The fan-in primitive. +- **`wait_for_agents`** — the fleet variant: await many panes (all / any). +- **`send_to_agent`** — drive an agent: optionally wait until it is ready, then + inject a prompt **atomically** so two concurrent drivers cannot corrupt each + other's keystrokes. +- A per-pane **drive lock** making every keystroke-injection path on a pane + mutually exclusive (the comprehensive chokepoint). +- Rails: an optional **idempotency key** on sends, a typed **`AgentTransition`** + event, and **structured transition logging**. + +This is the single most-reused capability across the surveyed orchestrators +(workmux `wait`, agent-deck "wake parent", herdr `wait agent-status`), and the +correctness primitives (drive lock, idempotency) are ones *none* of those four +ship because they each assume a single human operator — exactly the seam a +library that lets agents drive agents must own. + +### 1.1 North star: fewest backend calls ("instant magic") + +The defining performance property, by construction: + +- **Waits cost ZERO tmux calls.** The `AgentMonitor` drain already ingests the + control-mode stream into an in-process store. `wait_for_agent_state` does a + **level check** against that store (instant return if already satisfied) and + otherwise parks on an `asyncio.Future` woken by the *existing* drain — no + `list-panes`, no poll, no new subscription. `wait_for_agents` over N panes is + still zero calls. (Contrast: today's `watch_agents` blindly `sleep(timeout)`s.) +- **Every multi-step or fleet send folds to ONE dispatch.** `send_to_agent` + builds an `OpChain` / `LazyPlan` and runs it through the **`FoldingPlanner`**, + so a multi-line prompt (`set-buffer ; paste-buffer ; send-keys Enter`) and a + fleet broadcast (N panes' `send-keys` in one `a ; b ; c`) each dispatch as a + **single** `tmux` invocation. This is the chainable/lazy engine used to its + fullest: the most work per backend call, at zero extra cost. + +**Invariant:** a wait adds no tmux calls; a drive adds exactly one, regardless of +step- or fleet-count, whenever the constituent ops are chainable. + +--- + +## 2. Scope + +**In scope (v1):** `wait_for_agent_state`, `wait_for_agents`, `send_to_agent`, +`send_to_agents` (fleet, folded), the per-pane drive lock (comprehensive +chokepoint — also retrofitted onto the existing `asend_input`), the idempotency +key, the `AgentTransition` value + observer hook, and structured transition +logging. + +**Deferred (own specs / milestones), named so v1 stays a tight slice:** +- A4 `agents()` query + attention `rollup()` (query tier). +- A5 `freeze()` live-server → `Workspace` IR (declarative tier). +- `AgentState.DONE` (turn-complete-unseen) and failure substates. +- Composer-draft guard (presumes a human sharing the pane; speculative pane + mutation — opt-in at most, deferred). +- Wait-graph **deadlock guard**, **ownership/sibling-clobber authz**, and + **send-side pacing** — insurance rails, deferred (the monitor cut its + distributed-systems garnish the same way). + +--- + +## 3. Architecture (Approach 3: thin pure units, monitor-hosted) + +New units are small, pure, and unit-testable by feeding **synthetic** `Agent` +records — the same sans-I/O testing style as `store.py`'s reducer and +`signals.py`'s parsers. The `AgentMonitor` stays the single writer; nothing here +adds a second mutation path into the store. + +### 3.1 Module layout + +| File | Responsibility | +|---|---| +| `agents/wait.py` *(new)* | `WaitReason` enum, `AgentWait` result, `WaiterRegistry` (pure: `register(pred) -> Future`; `notify(agent)` resolves matches; `fail_all(reason)` on teardown), and the `wait_for_agent_state` / `wait_for_agents` async fns. | +| `agents/drive.py` *(new)* | `pane_lock(pane_id) -> asyncio.Lock` (module-level `WeakValueDictionary` chokepoint registry), `DedupLedger` (`(pane,key) -> SendOutcome` within a monotonic TTL), `SendOutcome`, and the `send_to_agent` / `send_to_agents` async fns. | +| `agents/state.py` *(touch)* | Add the pure `AgentTransition(pane_id, before, after, agent)` value next to `Agent`. | +| `agents/monitor.py` *(touch)* | `_observe` diffs `before -> after`; on change it (1) emits a structured `logger.info`, (2) calls `WaiterRegistry.notify`, (3) fans the `AgentTransition` to registered observers. The monitor **hosts** a `WaiterRegistry`; `start()`/`stop()` wire `fail_all` on teardown so no waiter hangs across a monitor stop. | +| `mcp/vocabulary/pane.py` *(touch)* | `asend_input` acquires `pane_lock` — the chokepoint retrofit (all async keystroke injection serializes per pane). | +| `mcp/vocabulary/agents.py` *(touch)* | Register `wait_for_agent` + `send_to_agent` MCP tools; wire into the adapter `_TOOLS`. | + +### 3.2 Types & signatures + +```python +# agents/wait.py +class WaitReason(str, enum.Enum): + REACHED = "reached" # the target state was observed + TIMEOUT = "timeout" # deadline elapsed first + EXITED = "exited" # agent reached EXITED and EXITED was not the target + STOPPED = "stopped" # the monitor stopped while the wait was parked + +# Note: the store has no distinct "vanished" state -- apply(Vanished) collapses a +# disappeared pane to AgentState.EXITED -- so a pane that dies before reaching the +# target resolves as EXITED (not a separate VANISHED reason). + +@dataclasses.dataclass(frozen=True) +class AgentWait: + pane_id: str + reason: WaitReason + agent: Agent | None # last-known record (None if never observed) + @property + def reached(self) -> bool: return self.reason is WaitReason.REACHED + +async def wait_for_agent_state( + monitor, pane_id: str, + target: AgentState | Collection[AgentState], + *, timeout: float | None = None, +) -> AgentWait: ... + +async def wait_for_agents( + monitor, pane_ids: Collection[str], + target: AgentState | Collection[AgentState], + *, mode: t.Literal["all", "any"] = "all", timeout: float | None = None, +) -> list[AgentWait]: ... # one per pane, in input order +``` + +```python +# agents/drive.py +@dataclasses.dataclass(frozen=True) +class SendOutcome: + pane_id: str + sent: bool # False if a readiness wait failed, or dedup no-op + wait: AgentWait | None # the readiness wait, when wait_ready=True + deduplicated: bool = False # True when an idempotency key short-circuited + +async def send_to_agent( + monitor, pane_id: str, text: str, *, + wait_ready: bool = True, + ready_states: Collection[AgentState] = (AgentState.AWAITING_INPUT, AgentState.IDLE), + enter: bool = True, + key: str | None = None, # idempotency key + timeout: float | None = None, +) -> SendOutcome: ... + +async def send_to_agents( + monitor, pane_ids: Collection[str], text: str, *, ... +) -> list[SendOutcome]: ... # ready sends fold into ONE dispatch +``` + +### 3.3 Wake-up mechanics (the level/edge seam) + +`wait_for_agent_state` closes the classic edge-vs-level gap by checking level +**before** awaiting the edge: + +1. **Level check.** Read `monitor`'s current `Agent` for `pane_id`. If it already + satisfies `target`, return `AgentWait(REACHED)` immediately — zero tmux calls, + zero awaits. +2. **Register.** Otherwise register a predicate with the `WaiterRegistry`, + receive a `Future`, and `await asyncio.wait_for(fut, timeout)`. +3. **Wake.** The monitor's `_observe`, after `apply()` changes a pane, calls + `registry.notify(agent)`; the registry resolves every waiter whose predicate + the new record satisfies (→ `REACHED`). A record that reached `EXITED` while a + non-exit target was awaited resolves that waiter with `EXITED` (terminal: + the agent is gone, the target is unreachable). +4. **Timeout / teardown.** `asyncio.TimeoutError` is caught → `AgentWait(TIMEOUT)` + and the waiter is deregistered in `finally` (no leak; a real `CancelledError` + still propagates). `monitor.stop()` calls `registry.fail_all()`, resolving + parked waits as `STOPPED` so they return rather than hang. + +Registration is synchronous and the registry is pure, so the level check + the +notify path are unit-testable with no event loop and no tmux. + +### 3.4 `send_to_agent` (folded, locked, idempotent) + +1. If `wait_ready`: `w = await wait_for_agent_state(monitor, pane_id, ready_states, + timeout)`; if `not w.reached` → `SendOutcome(sent=False, wait=w)` (no dispatch). +2. `async with pane_lock(pane_id):` — the whole logical send is atomic. +3. If `key` and `DedupLedger` has a live `(pane_id, key)` → return the prior + `SendOutcome` with `deduplicated=True` (no dispatch). This makes the obvious + "retry after a timeout" caller-reaction a safe no-op. +4. Build the send and fold it to one dispatch via `LazyPlan` + `FoldingPlanner`: + - single-line text → `SendKeys(keys=text, enter=enter)` (1 op, 1 dispatch); + - multi-line text → `SetBuffer(data=text) >> PasteBuffer(target, delete=True) + >> SendKeys(target, enter)` — all chainable, folds to one + `set-buffer ; paste-buffer ; send-keys Enter` invocation. +5. Record in the `DedupLedger`; return `SendOutcome(sent=True, wait=...)`. + +`send_to_agents` acquires per-pane locks in **sorted pane-id order** (avoids +lock-ordering deadlock between two concurrent fleet sends), folds every ready +pane's send into one chain, dispatches once, releases. + +### 3.5 The drive lock chokepoint + +`pane_lock(pane_id)` returns a process-wide `asyncio.Lock` from a +`WeakValueDictionary` (collected when no sender holds it). It guards the **async** +drive path — the real concurrency risk in the MCP async server, where many +coroutines share one event loop. Both `send_to_agent`/`send_to_agents` and the +retrofitted `asend_input` acquire it, so all async keystroke injection on a pane +serializes through one place. The engine's existing byte-level `_write_lock` is +untouched (it correctly guards pipe writes); this logical lock sits one layer up. + +### 3.6 Transition event + structured logging (rails) + +In `_observe`, when `apply()` changes a pane's state: +- `logger.info("agent state changed", extra={"tmux_pane": pane_id, + "agent_state_before": before.value, "agent_state_after": after.value, + "agent_name": name, "agent_source": source})` — reuses the existing + `tmux_pane` core key and introduces the documented `agent_*` key family + (the monitor today logs only an unstructured `DEBUG`). +- `monitor` fans an `AgentTransition` to observers registered via + `monitor.add_transition_observer(cb)`. (An MCP `watch_agent_transitions` push + tool is a thin follow-up; v1 ships the Python observer + the log.) + +--- + +## 4. Error handling & semantics + +- **Outcome-as-data** everywhere: `wait_*` never raise on timeout/vanish; they + return `AgentWait` with a typed `WaitReason`. Mirrors the settle monitor's + `MonitorResult`/`SettleReason`. (Engine-broken errors from a dispatched send + still surface as data on the `SendOutcome`'s underlying results, per the + engine's existing contract.) +- **Cancellation-safe:** a cancelled `wait_for_agent_state` deregisters its + waiter in `finally`; `pane_lock` is released by `async with`. +- **Async-only:** these verbs suspend on the event loop (like `wait_for_output` / + `watch_events`), so they have **no sync twin** — consistent with the streaming + MCP tools. + +--- + +## 5. MCP surface + +On the async server (beside `list_agents`/`watch_agents`): +- **`wait_for_agent(pane_id, target, timeout_s=30)`** → `AgentWait` as a dict. +- **`send_to_agent(pane_id, text, wait_ready=True, timeout_s=30, key=None)`** → + `SendOutcome` as a dict; tagged `mutating`. +- **Retrofit** `asend_input` to acquire `pane_lock` (no signature change). + +--- + +## 6. Testing strategy + +Per repo conventions (functional tests, existing fixtures, **no pytest-asyncio** — +async tests are `def test_…(): asyncio.run(...)`): + +- **Unit (no tmux):** `WaiterRegistry` register/notify/predicate/`fail_all`; + `AgentWait.reached`; level-check immediate return; timeout → `TIMEOUT`; + `Vanished` → `VANISHED`. `DriveLock` mutual exclusion (two coroutines, assert + serialized). `DedupLedger` TTL dedup. `send_to_agent` **folding** — drive a + recording engine and assert a multi-line send is **one** dispatch; assert a + not-ready pane yields `sent=False` with no dispatch; assert a dedup key + short-circuits. Feed `monitor.ingest(...)` synthetic notifications and assert + `wait_for_agent_state` resolves. +- **Live (real tmux, `session` fixture, `asyncio.run`):** `set-option @agent_state` + then assert `wait_for_agent_state` resolves `REACHED` within ~1.5 s; + `send_to_agent` into a shell pane and assert the text lands (capture). +- **Doctests** on every public symbol (`WaitReason`, `AgentWait`, `SendOutcome`, + the verbs) via `doctest_namespace`. +- **Gate** (before commit): `rm -rf docs/_build` → `ruff check . --fix` → + `ruff format .` → `mypy` → `pytest --reruns 0 -vvv` → `just build-docs`. + +--- + +## 7. Acceptance criteria (v1) + +1. `wait_for_agent_state` returns `REACHED` the moment a tracked agent reaches the + target, and `TIMEOUT`/`VANISHED` as data otherwise — adding **zero** tmux calls. +2. `send_to_agent` waits for readiness, then injects a multi-line prompt in a + **single** folded `tmux` dispatch; two concurrent sends to one pane serialize + (no interleave); a repeated idempotency key is a no-op. +3. `asend_input` shares the same per-pane lock (chokepoint proven by a + concurrency test). +4. Every monitor state change emits a structured `agent_*` log record and an + `AgentTransition` to observers. +5. The full gate passes: lint, types, unit + live tests, doctests, docs build. diff --git a/src/libtmux/experimental/agents/__init__.py b/src/libtmux/experimental/agents/__init__.py index cce9cebdd..834cef765 100644 --- a/src/libtmux/experimental/agents/__init__.py +++ b/src/libtmux/experimental/agents/__init__.py @@ -1,3 +1,40 @@ -"""Agent-state monitoring over tmux (experimental).""" +"""Agent-state monitoring and synchronization over tmux (experimental). + +The monitor (:class:`AgentMonitor`) tracks what every coding agent is doing; the +synchronization verbs act on that knowledge: :func:`wait_for_agent_state` / +:func:`wait_for_agents` block until agents reach a state (zero tmux calls), and +:func:`send_to_agent` / :func:`send_to_agents` drive them safely under a per-pane +:func:`pane_lock`, folding each send to a single tmux dispatch. +""" from __future__ import annotations + +from libtmux.experimental.agents.drive import ( + SendOutcome, + pane_lock, + send_to_agent, + send_to_agents, +) +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import Agent, AgentState, AgentTransition +from libtmux.experimental.agents.wait import ( + AgentWait, + WaitReason, + wait_for_agent_state, + wait_for_agents, +) + +__all__ = ( + "Agent", + "AgentMonitor", + "AgentState", + "AgentTransition", + "AgentWait", + "SendOutcome", + "WaitReason", + "pane_lock", + "send_to_agent", + "send_to_agents", + "wait_for_agent_state", + "wait_for_agents", +) diff --git a/src/libtmux/experimental/agents/drive.py b/src/libtmux/experimental/agents/drive.py new file mode 100644 index 000000000..af39623cd --- /dev/null +++ b/src/libtmux/experimental/agents/drive.py @@ -0,0 +1,272 @@ +"""Drive an agent safely: readiness-gated, atomically locked, folded to one call. + +:func:`send_to_agent` is the action half of the synchronization layer. It +optionally waits until the agent is ready (reusing +:func:`~libtmux.experimental.agents.wait.wait_for_agent_state`, zero tmux calls), +then injects the prompt **atomically** under a per-pane logical lock so two +concurrent drivers cannot interleave keystrokes, and **folds the whole send into +a single tmux dispatch** via the lazy/chainable plan + the +:class:`~libtmux.experimental.ops.planner.FoldingPlanner` -- a multi-line prompt +collapses to one ``set-buffer ; paste-buffer ; send-keys`` invocation, and a +fleet broadcast (:func:`send_to_agents`) folds N panes' sends into one call. + +The per-pane lock (:func:`pane_lock`) is a process-wide chokepoint: both these +verbs *and* the MCP ``asend_input`` tool acquire it, so all async keystroke +injection on a pane serializes through one place. The engine's byte-level +``_write_lock`` is untouched -- it guards pipe writes; this lock guards the whole +logical send one layer up. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import time +import typing as t +import weakref +from dataclasses import dataclass, field + +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.wait import ( + AgentWait, + wait_for_agent_state, + wait_for_agents, +) +from libtmux.experimental.ops import PasteBuffer, SendKeys, SetBuffer +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.plan import LazyPlan +from libtmux.experimental.ops.planner import FoldingPlanner + +if t.TYPE_CHECKING: + import asyncio + from collections.abc import Callable, Collection + + from libtmux.experimental.agents.monitor import AgentMonitor + +#: Default states an agent is considered "ready to receive a prompt" in. +READY_STATES: tuple[AgentState, ...] = (AgentState.AWAITING_INPUT, AgentState.IDLE) + +# Process-wide per-pane logical drive locks (the comprehensive chokepoint). Held +# weakly: a lock survives exactly as long as a sender references it (inside an +# ``async with``), so concurrent holders always share one object and an idle +# pane's lock is collected. +_LOCKS: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() + + +def pane_lock(pane_id: str) -> asyncio.Lock: + """Return the shared per-pane logical drive lock for *pane_id*. + + Examples + -------- + >>> import asyncio + >>> async def main(): + ... return pane_lock("%1") is pane_lock("%1") + >>> asyncio.run(main()) + True + """ + import asyncio + + lock = _LOCKS.get(pane_id) + if lock is None: + lock = asyncio.Lock() + _LOCKS[pane_id] = lock + return lock + + +@dataclass(frozen=True) +class SendOutcome: + """The result of a :func:`send_to_agent`, as data. + + Examples + -------- + >>> SendOutcome(pane_id="%1", sent=True).sent + True + """ + + pane_id: str + sent: bool + wait: AgentWait | None = None + deduplicated: bool = False + + +@dataclass +class DedupLedger: + """Short-TTL record of ``(pane, key)`` sends so a retry is a safe no-op. + + Examples + -------- + >>> clock = [0.0] + >>> ledger = DedupLedger(ttl=10.0, clock=lambda: clock[0]) + >>> ledger.get("%1", "turn-1") is None + True + >>> ledger.record("%1", "turn-1", SendOutcome("%1", sent=True)) + >>> ledger.get("%1", "turn-1").sent + True + >>> clock[0] = 20.0 # past the TTL + >>> ledger.get("%1", "turn-1") is None + True + """ + + ttl: float = 30.0 + clock: Callable[[], float] = time.monotonic + _entries: dict[tuple[str, str], tuple[float, SendOutcome]] = field( + default_factory=dict, + ) + + def get(self, pane_id: str, key: str) -> SendOutcome | None: + """Return the live outcome for ``(pane_id, key)``, or ``None`` if expired.""" + entry = self._entries.get((pane_id, key)) + if entry is None: + return None + deadline, outcome = entry + if self.clock() >= deadline: + del self._entries[(pane_id, key)] + return None + return outcome + + def record(self, pane_id: str, key: str, outcome: SendOutcome) -> None: + """Remember *outcome* for ``(pane_id, key)`` until ``ttl`` elapses.""" + self._entries[(pane_id, key)] = (self.clock() + self.ttl, outcome) + + +def _buffer_name(pane_id: str) -> str: + """Return a per-pane paste-buffer name (concurrent fleet sends never collide).""" + slug = "".join(char if char.isalnum() else "-" for char in pane_id) + return f"libtmux-send-{slug}" + + +def _add_send(plan: LazyPlan, pane_id: str, text: str, *, enter: bool) -> None: + """Record one agent send into *plan* (multi-line via a folded paste). + + Single-line text is one ``send-keys``; multi-line text is + ``set-buffer ; paste-buffer -p ; send-keys Enter`` -- all chainable, so a + :class:`~..ops.planner.FoldingPlanner` collapses each send to one dispatch. + """ + target = PaneId(pane_id) + if "\n" in text: + name = _buffer_name(pane_id) + plan.add(SetBuffer(data=text, buffer_name=name)) + plan.add( + PasteBuffer(target=target, buffer_name=name, delete=True, bracket=True), + ) + if enter: + plan.add(SendKeys(target=target, keys="Enter")) + else: + plan.add(SendKeys(target=target, keys=text, enter=enter)) + + +async def send_to_agent( + monitor: AgentMonitor, + pane_id: str, + text: str, + *, + wait_ready: bool = True, + ready_states: Collection[AgentState] = READY_STATES, + enter: bool = True, + key: str | None = None, + timeout: float | None = None, +) -> SendOutcome: + """Wait until *pane_id*'s agent is ready, then inject *text* in one dispatch. + + Steps: (1) if *wait_ready*, await the agent reaching one of *ready_states* + (zero tmux calls); a non-ready outcome returns ``sent=False`` with no + dispatch. (2) Acquire the per-pane :func:`pane_lock` so the whole send is + atomic. (3) If *key* names an already-delivered send within the dedup TTL, + return that no-op. (4) Fold the send to a single tmux call and dispatch it. + + Returns + ------- + SendOutcome + ``sent`` is whether the keystrokes were dispatched successfully. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.agents.monitor import AgentMonitor + >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + >>> outcome = asyncio.run(send_to_agent(mon, "%1", "echo hi")) + >>> outcome.sent + True + """ + wait_result: AgentWait | None = None + if wait_ready: + wait_result = await wait_for_agent_state( + monitor, pane_id, frozenset(ready_states), timeout=timeout + ) + if not wait_result.reached: + return SendOutcome(pane_id, sent=False, wait=wait_result) + + async with pane_lock(pane_id): + if key is not None: + prior = monitor.dedup.get(pane_id, key) + if prior is not None: + return dataclasses.replace(prior, wait=wait_result, deduplicated=True) + plan = LazyPlan() + _add_send(plan, pane_id, text, enter=enter) + result = await plan.aexecute(monitor.engine, planner=FoldingPlanner()) + outcome = SendOutcome(pane_id, sent=result.ok, wait=wait_result) + if key is not None: + monitor.dedup.record(pane_id, key, outcome) + return outcome + + +async def send_to_agents( + monitor: AgentMonitor, + pane_ids: Collection[str], + text: str, + *, + wait_ready: bool = True, + ready_states: Collection[AgentState] = READY_STATES, + enter: bool = True, + timeout: float | None = None, +) -> list[SendOutcome]: + """Broadcast *text* to many agents, folding every ready send into ONE dispatch. + + Waits for all panes' readiness (``mode="all"``), then acquires each ready + pane's lock in **sorted pane-id order** (deadlock-free against a concurrent + fleet send) and dispatches every ready pane's send as a single folded tmux + call. Returns one :class:`SendOutcome` per input pane, in order. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.agents.monitor import AgentMonitor + >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + >>> mon.ingest("%subscription-changed agentstate $0 @0 2 %2 : idle") + >>> outs = asyncio.run(send_to_agents(mon, ["%1", "%2"], "echo hi")) + >>> [o.sent for o in outs] + [True, True] + """ + panes = list(pane_ids) + waits: dict[str, AgentWait] = {} + if wait_ready: + for outcome in await wait_for_agents( + monitor, panes, frozenset(ready_states), mode="all", timeout=timeout + ): + waits[outcome.pane_id] = outcome + + outcomes: dict[str, SendOutcome] = {} + ready: list[str] = [] + for pane in panes: + if wait_ready and not waits[pane].reached: + outcomes[pane] = SendOutcome(pane, sent=False, wait=waits.get(pane)) + elif pane not in ready: + ready.append(pane) + + async with contextlib.AsyncExitStack() as stack: + for pane in sorted(ready): + await stack.enter_async_context(pane_lock(pane)) + ok = True + if ready: + plan = LazyPlan() + for pane in ready: + _add_send(plan, pane, text, enter=enter) + ok = (await plan.aexecute(monitor.engine, planner=FoldingPlanner())).ok + for pane in ready: + outcomes[pane] = SendOutcome(pane, sent=ok, wait=waits.get(pane)) + + return [outcomes[pane] for pane in panes] diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index bda087428..35931778b 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -22,11 +22,12 @@ import time import typing as t +from libtmux.experimental.agents.drive import DedupLedger from libtmux.experimental.agents.health import is_alive from libtmux.experimental.agents.hud import HudRenderer from libtmux.experimental.agents.merge import MonotonicCounter, Stamp from libtmux.experimental.agents.signals import SUBSCRIPTION, OptionSignal, OscSignal -from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.state import AgentState, AgentTransition from libtmux.experimental.agents.store import ( AgentStore, Observed, @@ -35,8 +36,11 @@ apply, ) from libtmux.experimental.agents.tree import PANE_FORMAT, diff_panes, panes_of +from libtmux.experimental.agents.wait import WaiterRegistry if t.TYPE_CHECKING: + from collections.abc import Callable + from libtmux.experimental.agents.merge import Clock from libtmux.experimental.agents.signals import Reading from libtmux.experimental.agents.state import Agent @@ -50,6 +54,17 @@ _PANE_FORMAT_STR = _SEP.join(f"#{{{field}}}" for field in PANE_FORMAT) +def _notify_observer( + observer: Callable[[AgentTransition], None], + transition: AgentTransition, +) -> None: + """Invoke a transition observer, swallowing its errors (kept out of the loop).""" + try: + observer(transition) + except Exception: + logger.debug("agent transition observer failed", exc_info=True) + + class AgentMonitor: """Wire signals, the coalescing store, and the engine event loop. @@ -108,6 +123,12 @@ def __init__( # Bounded poll between reconcile retries while the engine is reconnecting, # so the supervised drain waits (not busy-spins) for the engine to revive. self._reconnect_poll = 0.5 + # Synchronization layer: waiters parked by wait_for_agent_state (woken + # from the single _observe/reconcile mutation point), the send + # idempotency ledger, and transition observers. + self._waiters = WaiterRegistry() + self._dedup = DedupLedger() + self._transition_observers: list[Callable[[AgentTransition], None]] = [] # Seed the store from a persistent sink when one is provided. if sink is not None: @@ -185,7 +206,9 @@ def _observe(self, reading: Reading) -> None: source=reading.source, pid=None, ) + before = self._store.agents.get(reading.pane_id) self._store = apply(self._store, observed, now=time.monotonic()) + after = self._store.agents.get(reading.pane_id) self._hud_dirty = True if self._sink is not None: self._sink.save(self._store.to_dict()) @@ -195,6 +218,7 @@ def _observe(self, reading: Reading) -> None: reading.pane_id, reading.source, ) + self._notify_change(before, after) # ------------------------------------------------------------------ # Public read API @@ -251,6 +275,113 @@ def status(self) -> dict[str, t.Any]: "generation": getattr(self._engine, "_generation", 0), } + @property + def waiters(self) -> WaiterRegistry: + """The registry backing :func:`~..wait.wait_for_agent_state`. + + Examples + -------- + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.agents.wait import WaiterRegistry + >>> isinstance(AgentMonitor(AsyncConcreteEngine()).waiters, WaiterRegistry) + True + """ + return self._waiters + + @property + def dedup(self) -> DedupLedger: + """The idempotency ledger backing :func:`~..drive.send_to_agent` keys. + + Examples + -------- + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.agents.drive import DedupLedger + >>> isinstance(AgentMonitor(AsyncConcreteEngine()).dedup, DedupLedger) + True + """ + return self._dedup + + @property + def engine(self) -> t.Any: + """The async engine this monitor drives (reused by ``send_to_agent``). + + Examples + -------- + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> e = AsyncConcreteEngine() + >>> AgentMonitor(e).engine is e + True + """ + return self._engine + + def agent_for(self, pane_id: str) -> Agent | None: + """Return the current :class:`~..state.Agent` for *pane_id*, or ``None``. + + Examples + -------- + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> mon.agent_for("%1") is None + True + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + >>> mon.agent_for("%1").state + + """ + return self._store.agents.get(pane_id) + + def add_transition_observer( + self, + callback: Callable[[AgentTransition], None], + ) -> None: + """Register *callback*, invoked with each :class:`~..state.AgentTransition`. + + Called on every state change (the edge), after the store is updated. + + Examples + -------- + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> seen = [] + >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> mon.add_transition_observer(seen.append) + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + >>> seen[0].after + + """ + self._transition_observers.append(callback) + + def _notify_change(self, before: Agent | None, after: Agent | None) -> None: + """Wake waiters and emit the transition edge for a changed pane record. + + Called from every store-mutation site (``_observe``, the reconcile + ``Vanished`` loop, the health sweep). Logs a structured ``agent_*`` + record and fans an :class:`~..state.AgentTransition` to observers only on + an actual *state* change; waiters are notified on any updated record so + their predicates re-evaluate. + """ + if after is None: + return + state_changed = before is None or before.state is not after.state + if state_changed: + logger.info( + "agent state changed", + extra={ + "tmux_pane": after.pane_id, + "agent_state_before": before.state.value if before else None, + "agent_state_after": after.state.value, + "agent_name": after.name, + "agent_source": after.source, + }, + ) + transition = AgentTransition( + pane_id=after.pane_id, + before=before.state if before else None, + after=after.state, + agent=after, + ) + for observer in tuple(self._transition_observers): + _notify_observer(observer, transition) + self._waiters.notify(after) + # ------------------------------------------------------------------ # Async lifecycle # ------------------------------------------------------------------ @@ -414,6 +545,7 @@ async def stop(self) -> None: with contextlib.suppress(asyncio.CancelledError): await self._task self._task = None + self._waiters.fail_all() await self._teardown_hud() if self._sink is not None: self._sink.save(self._store.to_dict()) @@ -456,11 +588,13 @@ async def _reconcile_once(self) -> None: } _added, removed = diff_panes(self._prev_panes, current_panes) for pane_id in removed: + before = self._store.agents.get(pane_id) self._store = apply( self._store, Vanished(pane_id=pane_id), now=time.monotonic(), ) + self._notify_change(before, self._store.agents.get(pane_id)) self._apply_health(current_panes) self._prev_panes = dict(current_panes) self._hud_dirty = True @@ -490,6 +624,7 @@ def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: """ agents = dict(self._store.agents) changed = False + exits: list[tuple[Agent, Agent]] = [] now = time.monotonic() for pane_id, agent in self._store.agents.items(): pane = current_panes.get(pane_id) @@ -498,19 +633,23 @@ def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: pid = pane.pid if pid is not None and not is_alive(pid): if agent.alive or agent.state is not AgentState.EXITED: - agents[pane_id] = dataclasses.replace( + exited = dataclasses.replace( agent, state=AgentState.EXITED, alive=False, pid=pid, since=now, ) + agents[pane_id] = exited + exits.append((agent, exited)) changed = True elif agent.pid != pid or not agent.alive: agents[pane_id] = dataclasses.replace(agent, pid=pid, alive=True) changed = True if changed: self._store = AgentStore(agents=agents, stamps=dict(self._store.stamps)) + for before, after in exits: + self._notify_change(before, after) # ------------------------------------------------------------------ # Private helpers diff --git a/src/libtmux/experimental/agents/state.py b/src/libtmux/experimental/agents/state.py index 757dcf413..5c4804d00 100644 --- a/src/libtmux/experimental/agents/state.py +++ b/src/libtmux/experimental/agents/state.py @@ -90,3 +90,27 @@ def is_running(self) -> bool: True """ return self.state is AgentState.RUNNING + + +@dataclass(frozen=True) +class AgentTransition: + """One observed change in a pane's agent state. + + Published by the monitor on every state change (the edge an orchestrator + reacts to) and carried to transition observers; *before* is ``None`` for a + pane's first observation. + + Examples + -------- + >>> agent = Agent(pane_id="%1", key="%1", name="claude", + ... state=AgentState.AWAITING_INPUT, since=1.0, + ... source="option", pid=42, alive=True) + >>> AgentTransition(pane_id="%1", before=AgentState.RUNNING, + ... after=AgentState.AWAITING_INPUT, agent=agent).after + + """ + + pane_id: str + before: AgentState | None + after: AgentState + agent: Agent diff --git a/src/libtmux/experimental/agents/wait.py b/src/libtmux/experimental/agents/wait.py new file mode 100644 index 000000000..819da6085 --- /dev/null +++ b/src/libtmux/experimental/agents/wait.py @@ -0,0 +1,382 @@ +"""Block until an agent reaches a state -- the fan-in synchronization verbs. + +:func:`wait_for_agent_state` and the fleet :func:`wait_for_agents` resolve the +*moment* the :class:`~libtmux.experimental.agents.monitor.AgentMonitor`'s +in-process store shows a pane's agent reach a target state, so **a wait costs +zero tmux calls**: the monitor's drain already ingests the control-mode stream, +and the wait either returns immediately (a level check against the store) or +parks on a future the drain wakes. Outcomes come back as *data* -- an +:class:`AgentWait` carrying a typed :class:`WaitReason` -- never a raise on +timeout, so the fleet variant can report one outcome per pane. + +This is the event-driven twin of the command-settle monitor +(:func:`~libtmux.experimental.mcp._settle.accumulate_until_settle`): same +"return on the event, not on a fixed sleep" shape, applied to agent state. +""" + +from __future__ import annotations + +import asyncio +import enum +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.agents.state import AgentState + +if t.TYPE_CHECKING: + from collections.abc import Callable, Collection + + from libtmux.experimental.agents.monitor import AgentMonitor + from libtmux.experimental.agents.state import Agent + + +class WaitReason(str, enum.Enum): + """Why a :func:`wait_for_agent_state` returned. + + Examples + -------- + >>> WaitReason.REACHED.value + 'reached' + """ + + REACHED = "reached" + """The target state was observed.""" + TIMEOUT = "timeout" + """The deadline elapsed before the target was reached.""" + EXITED = "exited" + """The agent reached EXITED (and EXITED was not the target).""" + STOPPED = "stopped" + """The monitor stopped while the wait was parked.""" + + +@dataclass(frozen=True) +class AgentWait: + """The outcome of a wait, as data (never raised). + + Examples + -------- + >>> AgentWait(pane_id="%1", reason=WaitReason.REACHED, agent=None).reached + True + >>> AgentWait(pane_id="%1", reason=WaitReason.TIMEOUT, agent=None).reached + False + """ + + pane_id: str + reason: WaitReason + agent: Agent | None + + @property + def reached(self) -> bool: + """Whether the target state was actually reached.""" + return self.reason is WaitReason.REACHED + + +def _as_target_set( + target: AgentState | Collection[AgentState], +) -> frozenset[AgentState]: + """Normalize a single state or a collection of states to a frozenset. + + ``AgentState`` is checked first because it is a ``str`` subclass (and so also + a ``Collection``); without the guard a lone state would iterate its characters. + + Examples + -------- + >>> sorted(s.value for s in _as_target_set(AgentState.IDLE)) + ['idle'] + >>> sorted(s.value for s in _as_target_set( + ... {AgentState.IDLE, AgentState.AWAITING_INPUT})) + ['awaiting_input', 'idle'] + """ + if isinstance(target, AgentState): + return frozenset({target}) + return frozenset(target) + + +def _resolve_reason( + predicate: Callable[[Agent], bool], + agent: Agent, +) -> WaitReason | None: + """Decide whether *agent* settles a waiter, and how (pure, loop-free). + + Returns :attr:`WaitReason.REACHED` when the predicate matches, + :attr:`WaitReason.EXITED` when the agent is terminally gone and the predicate + did not match (the target is now unreachable), or ``None`` to keep waiting. + + Examples + -------- + >>> from libtmux.experimental.agents.state import Agent + >>> running = Agent(pane_id="%1", key="%1", name=None, + ... state=AgentState.RUNNING, since=0.0, source="option", + ... pid=1, alive=True) + >>> _resolve_reason(lambda a: a.state is AgentState.IDLE, running) is None + True + >>> exited = Agent(pane_id="%1", key="%1", name=None, + ... state=AgentState.EXITED, since=0.0, source="option", + ... pid=1, alive=False) + >>> _resolve_reason(lambda a: a.state is AgentState.IDLE, exited) + + """ + if predicate(agent): + return WaitReason.REACHED + if agent.state is AgentState.EXITED: + return WaitReason.EXITED + return None + + +@dataclass +class _Waiter: + """One parked wait: a settle predicate plus the future to resolve.""" + + predicate: Callable[[Agent], bool] + future: asyncio.Future[tuple[WaitReason, Agent | None]] + + +class WaiterRegistry: + """Per-pane registry of parked waiters; the monitor drives :meth:`notify`. + + Pure coordination state (no tmux, no store): the monitor calls + :meth:`notify` from its single ``_observe`` mutation point whenever a pane's + agent changes, and every waiter whose predicate the new record settles is + resolved. Keyed by pane id so a notification only scans that pane's waiters. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.agents.state import Agent + >>> reg = WaiterRegistry() + >>> async def main(): + ... waiter = reg.register("%1", lambda a: a.state is AgentState.IDLE) + ... reg.notify(Agent(pane_id="%1", key="%1", name=None, + ... state=AgentState.IDLE, since=0.0, source="option", + ... pid=1, alive=True)) + ... return await waiter.future + >>> reason, agent = asyncio.run(main()) + >>> reason + + """ + + def __init__(self) -> None: + self._waiters: dict[str, list[_Waiter]] = {} + + def register( + self, + pane_id: str, + predicate: Callable[[Agent], bool], + ) -> _Waiter: + """Park a waiter for *pane_id*; the future resolves on a matching notify. + + Must be called from within a running event loop (the future is bound to + it). Returns the :class:`_Waiter` so the caller can :meth:`discard` it. + """ + loop = asyncio.get_running_loop() + waiter = _Waiter(predicate, loop.create_future()) + self._waiters.setdefault(pane_id, []).append(waiter) + return waiter + + def notify(self, agent: Agent) -> None: + """Resolve every waiter on *agent*'s pane that the record now settles.""" + waiters = self._waiters.get(agent.pane_id) + if not waiters: + return + remaining: list[_Waiter] = [] + for waiter in waiters: + if waiter.future.done(): + continue + reason = _resolve_reason(waiter.predicate, agent) + if reason is None: + remaining.append(waiter) + else: + waiter.future.set_result((reason, agent)) + if remaining: + self._waiters[agent.pane_id] = remaining + else: + self._waiters.pop(agent.pane_id, None) + + def discard(self, pane_id: str, waiter: _Waiter) -> None: + """Forget *waiter* (called from the wait's ``finally``; no leak).""" + waiters = self._waiters.get(pane_id) + if not waiters: + return + kept = [existing for existing in waiters if existing is not waiter] + if kept: + self._waiters[pane_id] = kept + else: + self._waiters.pop(pane_id, None) + + def fail_all(self) -> None: + """Resolve every parked waiter as :attr:`WaitReason.STOPPED` and clear. + + Called by :meth:`~..monitor.AgentMonitor.stop` so no wait hangs once the + monitor that would wake it is gone. + """ + for waiters in self._waiters.values(): + for waiter in waiters: + if not waiter.future.done(): + waiter.future.set_result((WaitReason.STOPPED, None)) + self._waiters.clear() + + +async def wait_for_agent_state( + monitor: AgentMonitor, + pane_id: str, + target: AgentState | Collection[AgentState], + *, + timeout: float | None = None, +) -> AgentWait: + """Block until *pane_id*'s agent reaches *target*; return the outcome as data. + + Adds **zero** tmux calls: a level check against the monitor's store returns + immediately when the target (or a terminal EXITED) already holds, otherwise + the call parks on a future the monitor's drain wakes. + + Parameters + ---------- + monitor : AgentMonitor + The running monitor whose store is the source of agent state. + pane_id : str + The pane to watch (e.g. ``"%1"``). + target : AgentState or Collection[AgentState] + The state(s) that satisfy the wait. + timeout : float or None + Seconds before giving up; ``None`` waits indefinitely. + + Returns + ------- + AgentWait + ``reason`` is ``REACHED``/``TIMEOUT``/``EXITED``/``STOPPED``. + + Examples + -------- + A pane already in the target state returns immediately (no await on tmux): + + >>> import asyncio + >>> from libtmux.experimental.agents.monitor import AgentMonitor + >>> class _Fake: + ... async def run(self, request): ... + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> mon = AgentMonitor(_Fake()) + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + >>> outcome = asyncio.run( + ... wait_for_agent_state(mon, "%1", AgentState.IDLE, timeout=1.0)) + >>> outcome.reason + + + A pane that never reaches the target times out as data (no raise): + + >>> late = asyncio.run( + ... wait_for_agent_state(mon, "%1", AgentState.RUNNING, timeout=0.05)) + >>> late.reason + + """ + targets = _as_target_set(target) + + def predicate(agent: Agent) -> bool: + return agent.state in targets + + current = monitor.agent_for(pane_id) + if current is not None: + if predicate(current): + return AgentWait(pane_id, WaitReason.REACHED, current) + if current.state is AgentState.EXITED: + return AgentWait(pane_id, WaitReason.EXITED, current) + + waiter = monitor.waiters.register(pane_id, predicate) + try: + reason, settled = await asyncio.wait_for(waiter.future, timeout) + return AgentWait(pane_id, reason, settled) + except (asyncio.TimeoutError, TimeoutError): + return AgentWait(pane_id, WaitReason.TIMEOUT, monitor.agent_for(pane_id)) + finally: + monitor.waiters.discard(pane_id, waiter) + + +async def wait_for_agents( + monitor: AgentMonitor, + pane_ids: Collection[str], + target: AgentState | Collection[AgentState], + *, + mode: t.Literal["all", "any"] = "all", + timeout: float | None = None, +) -> list[AgentWait]: + """Await many panes at once; return one :class:`AgentWait` per pane, in order. + + ``mode="all"`` resolves each pane independently (the list reports every + pane's true outcome). ``mode="any"`` returns as soon as the first pane + reaches *target*; panes that had not reached it by then are reported by a + final level check (``REACHED``/``EXITED`` if they happen to satisfy it now, + else ``TIMEOUT`` meaning "did not reach before the call returned"). + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.agents.monitor import AgentMonitor + >>> class _Fake: + ... async def run(self, request): ... + ... async def subscribe(self): ... + ... def add_subscription(self, spec): ... + ... def set_attach_targets(self, ids): ... + >>> mon = AgentMonitor(_Fake()) + >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + >>> mon.ingest("%subscription-changed agentstate $0 @0 2 %2 : idle") + >>> outs = asyncio.run( + ... wait_for_agents(mon, ["%1", "%2"], AgentState.IDLE, timeout=1.0)) + >>> [o.reached for o in outs] + [True, True] + """ + panes = list(pane_ids) + if not panes: + return [] + if mode == "all": + results = await asyncio.gather( + *( + wait_for_agent_state(monitor, pane, target, timeout=timeout) + for pane in panes + ) + ) + return list(results) + + # mode == "any": race the per-pane waits, settle on the first REACHED. Shrink + # the pending set each round so a non-reached completion (EXITED) cannot + # re-spin the loop on an already-done task. + loop = asyncio.get_running_loop() + deadline = None if timeout is None else loop.time() + timeout + tasks = { + pane: asyncio.ensure_future( + wait_for_agent_state(monitor, pane, target, timeout=timeout) + ) + for pane in panes + } + targets = _as_target_set(target) + pending = set(tasks.values()) + reached = False + try: + while pending and not reached: + remaining = None if deadline is None else max(0.0, deadline - loop.time()) + done, pending = await asyncio.wait( + pending, + timeout=remaining, + return_when=asyncio.FIRST_COMPLETED, + ) + if not done: + break # deadline elapsed + reached = any(task.result().reached for task in done) + finally: + for task in tasks.values(): + if not task.done(): + task.cancel() + + outcomes: list[AgentWait] = [] + for pane, task in tasks.items(): + if task.done() and not task.cancelled(): + outcomes.append(task.result()) + continue + current = monitor.agent_for(pane) + if current is not None and current.state in targets: + outcomes.append(AgentWait(pane, WaitReason.REACHED, current)) + elif current is not None and current.state is AgentState.EXITED: + outcomes.append(AgentWait(pane, WaitReason.EXITED, current)) + else: + outcomes.append(AgentWait(pane, WaitReason.TIMEOUT, current)) + return outcomes diff --git a/src/libtmux/experimental/mcp/vocabulary/agents.py b/src/libtmux/experimental/mcp/vocabulary/agents.py index 2852f6a29..83d153139 100644 --- a/src/libtmux/experimental/mcp/vocabulary/agents.py +++ b/src/libtmux/experimental/mcp/vocabulary/agents.py @@ -115,8 +115,11 @@ def register_agents( from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations + from libtmux.experimental.agents.drive import send_to_agent as drive_send_to_agent from libtmux.experimental.agents.hooks.registry import get from libtmux.experimental.agents.monitor import AgentMonitor + from libtmux.experimental.agents.state import AgentState + from libtmux.experimental.agents.wait import wait_for_agent_state if monitor is None: monitor = AgentMonitor(engine, sink=sink) @@ -262,4 +265,126 @@ def _install_and_status() -> str: ), ) + # ------------------------------------------------------------------ + # wait_for_agent + # ------------------------------------------------------------------ + + async def wait_for_agent( + pane_id: str, + target: str, + timeout_s: float = 30.0, + ) -> dict[str, t.Any]: + """Block until a pane's agent reaches a target state. + + Adds no tmux round-trip: the wait is served from the monitor's live + store (the drain already ingests the stream), returning the moment the + target is observed or *timeout_s* elapses. + + Parameters + ---------- + pane_id : str + The pane to watch (e.g. ``"%1"``). + target : str + One state or a comma-separated set (e.g. ``"awaiting_input,idle"``). + timeout_s : float + Seconds to wait before giving up (default 30). + + Returns + ------- + dict[str, Any] + ``pane_id``, ``reason`` (``reached``/``timeout``/``exited``/ + ``stopped``), ``reached``, and the last-known ``state``/``name``. + """ + states = frozenset( + AgentState.from_signal(part) for part in target.split(",") if part.strip() + ) + outcome = await wait_for_agent_state( + monitor, pane_id, states, timeout=timeout_s + ) + agent = outcome.agent + return { + "pane_id": outcome.pane_id, + "reason": outcome.reason.value, + "reached": outcome.reached, + "state": agent.state.value if agent else None, + "name": agent.name if agent else None, + } + + mcp.add_tool( + FunctionTool.from_function( + wait_for_agent, + name="wait_for_agent", + description=( + "Block until a coding-agent pane reaches a target state " + "(zero tmux calls; served from the live agent store)" + ), + tags={"readonly", "agents"}, + annotations=ToolAnnotations(title="wait_for_agent", readOnlyHint=True), + ), + ) + + # ------------------------------------------------------------------ + # send_to_agent + # ------------------------------------------------------------------ + + async def send_to_agent( + pane_id: str, + text: str, + wait_ready: bool = True, + timeout_s: float = 30.0, + key: str | None = None, + ) -> dict[str, t.Any]: + """Wait until the agent is ready, then inject *text* in one folded dispatch. + + The send is atomic under the per-pane drive lock (concurrent sends + serialize) and folds to a single tmux command. An optional *key* makes a + retried send a no-op within a short window. + + Parameters + ---------- + pane_id : str + The agent's pane (e.g. ``"%1"``). + text : str + The prompt to deliver (multi-line is pasted, then submitted). + wait_ready : bool + Wait for ``awaiting_input``/``idle`` before sending (default True). + timeout_s : float + Readiness-wait budget in seconds (default 30). + key : str or None + Idempotency key; a repeat within the dedup TTL is a no-op. + + Returns + ------- + dict[str, Any] + ``pane_id``, ``sent``, ``deduplicated``, and the readiness ``wait`` + reason (or ``None`` when *wait_ready* is False). + """ + outcome = await drive_send_to_agent( + monitor, + pane_id, + text, + wait_ready=wait_ready, + timeout=timeout_s, + key=key, + ) + return { + "pane_id": outcome.pane_id, + "sent": outcome.sent, + "deduplicated": outcome.deduplicated, + "wait": outcome.wait.reason.value if outcome.wait else None, + } + + mcp.add_tool( + FunctionTool.from_function( + send_to_agent, + name="send_to_agent", + description=( + "Wait until a coding agent is ready, then send it a prompt " + "atomically in a single folded tmux dispatch" + ), + tags={"mutating", "agents"}, + annotations=ToolAnnotations(title="send_to_agent", readOnlyHint=False), + ), + ) + return monitor diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 35a2b99d0..2dccb06c9 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -158,22 +158,31 @@ async def asend_input( suppress_history: bool = False, version: str | None = None, ) -> None: - """Send keys to a pane (mirrors ``pane.send_keys``).""" + """Send keys to a pane (mirrors ``pane.send_keys``). + + Acquires the per-pane logical drive lock (the chokepoint shared with + ``send_to_agent``) so two concurrent sends to one pane serialize instead of + interleaving keystrokes. + """ + from libtmux.experimental.agents.drive import pane_lock + from libtmux.experimental.ops._types import render_target + resolved = resolve_target(target) reject_relative_special(resolved) - ( - await arun( - SendKeys( - target=resolved, - keys=keys, - enter=enter, - literal=literal, - suppress_history=suppress_history, - ), - engine, - version=version, - ) - ).raise_for_status() + async with pane_lock(render_target(resolved) or str(resolved)): + ( + await arun( + SendKeys( + target=resolved, + keys=keys, + enter=enter, + literal=literal, + suppress_history=suppress_history, + ), + engine, + version=version, + ) + ).raise_for_status() async def acapture_pane( diff --git a/tests/experimental/agents/test_drive.py b/tests/experimental/agents/test_drive.py new file mode 100644 index 000000000..491234c4f --- /dev/null +++ b/tests/experimental/agents/test_drive.py @@ -0,0 +1,137 @@ +"""Unit tests for the drive verbs: lock, dedup, folding (no live tmux).""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.agents.drive import ( + DedupLedger, + SendOutcome, + pane_lock, + send_to_agent, + send_to_agents, +) +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.wait import WaitReason +from libtmux.experimental.engines.base import CommandRequest, CommandResult + + +class _RecordingEngine: + """An async engine that records every dispatched request and succeeds.""" + + def __init__(self) -> None: + self.requests: list[CommandRequest] = [] + + async def run(self, request: CommandRequest) -> CommandResult: + self.requests.append(request) + return CommandResult(cmd=request.args, returncode=0) + + +def _idle_monitor() -> tuple[AgentMonitor, _RecordingEngine]: + engine = _RecordingEngine() + monitor = AgentMonitor(engine) + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + return monitor, engine + + +def test_pane_lock_is_shared_and_serializes() -> None: + """Two senders on one pane never overlap; different panes do not block.""" + + async def main() -> tuple[list[tuple[str, int]], bool]: + order: list[tuple[str, int]] = [] + + async def worker(n: int) -> None: + async with pane_lock("%1"): + order.append(("enter", n)) + await asyncio.sleep(0.02) + order.append(("exit", n)) + + await asyncio.gather(worker(1), worker(2)) + different = pane_lock("%9") is not pane_lock("%8") + return order, different + + order, different = asyncio.run(main()) + # Each enter must be immediately followed by its own exit (no interleave). + assert order[0][0] == "enter" + assert order[1] == ("exit", order[0][1]) + assert order[2][0] == "enter" + assert order[3] == ("exit", order[2][1]) + assert different is True + + +def test_dedup_ledger_ttl() -> None: + """A key is live within the TTL and forgotten after it elapses.""" + clock = [0.0] + ledger = DedupLedger(ttl=10.0, clock=lambda: clock[0]) + assert ledger.get("%1", "k") is None + ledger.record("%1", "k", SendOutcome("%1", sent=True)) + assert ledger.get("%1", "k") is not None + clock[0] = 20.0 + assert ledger.get("%1", "k") is None + + +def test_send_to_agent_folds_multiline_into_one_dispatch() -> None: + """A multi-line prompt dispatches as a single folded set/paste/send call.""" + + async def main() -> tuple[bool, int, CommandRequest]: + monitor, engine = _idle_monitor() + outcome = await send_to_agent(monitor, "%1", "line one\nline two") + return outcome.sent, len(engine.requests), engine.requests[0] + + sent, dispatch_count, request = asyncio.run(main()) + assert sent is True + assert dispatch_count == 1 # the whole multi-step send is one tmux call + args = request.args + assert "set-buffer" in args + assert "paste-buffer" in args + assert "send-keys" in args + assert ";" in args # the ops are chained into one invocation + + +def test_send_to_agent_skips_dispatch_when_not_ready() -> None: + """A pane stuck RUNNING is not driven; no keystrokes are dispatched.""" + + async def main() -> tuple[SendOutcome, int]: + engine = _RecordingEngine() + monitor = AgentMonitor(engine) + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + outcome = await send_to_agent(monitor, "%1", "go", timeout=0.05) + return outcome, len(engine.requests) + + outcome, dispatch_count = asyncio.run(main()) + assert outcome.sent is False + assert outcome.wait is not None + assert outcome.wait.reason is WaitReason.TIMEOUT + assert dispatch_count == 0 + + +def test_send_to_agent_idempotency_key_is_a_no_op_on_retry() -> None: + """A repeated send under the same key dispatches once and is flagged.""" + + async def main() -> tuple[SendOutcome, SendOutcome, int]: + monitor, engine = _idle_monitor() + first = await send_to_agent(monitor, "%1", "hi", key="turn-1") + second = await send_to_agent(monitor, "%1", "hi", key="turn-1") + return first, second, len(engine.requests) + + first, second, dispatch_count = asyncio.run(main()) + assert first.sent is True + assert first.deduplicated is False + assert second.deduplicated is True + assert dispatch_count == 1 # the retry did not re-send + + +def test_send_to_agents_folds_fleet_into_one_dispatch() -> None: + """A broadcast to N ready panes dispatches as a single folded call.""" + + async def main() -> tuple[list[bool], int]: + engine = _RecordingEngine() + monitor = AgentMonitor(engine) + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + monitor.ingest("%subscription-changed agentstate $0 @0 2 %2 : idle") + outs = await send_to_agents(monitor, ["%1", "%2"], "ping") + return [o.sent for o in outs], len(engine.requests) + + sent, dispatch_count = asyncio.run(main()) + assert sent == [True, True] + assert dispatch_count == 1 # both panes' sends folded into one tmux call diff --git a/tests/experimental/agents/test_sync_live.py b/tests/experimental/agents/test_sync_live.py new file mode 100644 index 000000000..4c6fe18ac --- /dev/null +++ b/tests/experimental/agents/test_sync_live.py @@ -0,0 +1,83 @@ +"""Live: wait_for_agent_state and send_to_agent against a real tmux server.""" + +from __future__ import annotations + +import asyncio +import typing as t + +if t.TYPE_CHECKING: + from libtmux.session import Session + +from libtmux.experimental.agents.drive import send_to_agent +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.wait import WaitReason, wait_for_agent_state +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +def test_wait_for_agent_state_resolves_on_real_option(session: Session) -> None: + """A real @agent_state write wakes a parked wait (zero polling). + + Starts the monitor (self-attaches for the option channel), parks a wait for + IDLE, then writes the option a beat later -- proving the wait resolves on the + drain's ingest, not a fixed sleep. + """ + + async def main() -> WaitReason: + engine = AsyncControlModeEngine.for_server(session.server) + monitor = AgentMonitor(engine) + await monitor.start() + active = session.active_window.active_pane + assert active is not None + pane_id = active.pane_id + assert pane_id is not None + + async def setter() -> None: + await asyncio.sleep(0.2) + session.cmd("set-option", "-p", "-t", pane_id, "@agent_state", "idle") + + task = asyncio.create_task(setter()) + outcome = await wait_for_agent_state( + monitor, pane_id, AgentState.IDLE, timeout=6.0 + ) + await task + await monitor.stop() + await engine.aclose() + return outcome.reason + + assert asyncio.run(main()) is WaitReason.REACHED + + +def test_send_to_agent_text_lands_in_pane(session: Session) -> None: + """send_to_agent injects keystrokes that reach a real shell pane. + + Uses ``wait_ready=False`` because the pane runs a plain shell (no agent + hook); the dispatched ``echo`` output must show up in a capture. + """ + + async def main() -> bool: + engine = AsyncControlModeEngine.for_server(session.server) + monitor = AgentMonitor(engine) + await monitor.start() + pane = session.active_window.active_pane + assert pane is not None + pane_id = pane.pane_id + assert pane_id is not None + + marker = "libtmux_sync_marker_42" + outcome = await send_to_agent( + monitor, pane_id, f"echo {marker}", wait_ready=False + ) + assert outcome.sent is True + + landed = False + for _ in range(30): + await asyncio.sleep(0.1) + if any(marker in line for line in pane.capture_pane()): + landed = True + break + await monitor.stop() + await engine.aclose() + return landed + + assert asyncio.run(main()) is True diff --git a/tests/experimental/agents/test_wait.py b/tests/experimental/agents/test_wait.py new file mode 100644 index 000000000..88455d013 --- /dev/null +++ b/tests/experimental/agents/test_wait.py @@ -0,0 +1,173 @@ +"""Unit tests for the wait synchronization verbs (no live tmux).""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import Agent, AgentState +from libtmux.experimental.agents.wait import ( + WaiterRegistry, + WaitReason, + _resolve_reason, + wait_for_agent_state, + wait_for_agents, +) + + +class _FakeEngine: + async def run(self, request: object) -> None: ... + + async def subscribe(self) -> None: ... + + def add_subscription(self, spec: object) -> None: ... + + def set_attach_targets(self, ids: object) -> None: ... + + +def _agent(pane_id: str, state: AgentState) -> Agent: + return Agent( + pane_id=pane_id, + key=pane_id, + name="claude", + state=state, + since=0.0, + source="option", + pid=1, + alive=state is not AgentState.EXITED, + ) + + +def test_resolve_reason_matches_predicate() -> None: + """A satisfying record reaches; a terminal EXITED ends an unmet wait.""" + running = _agent("%1", AgentState.RUNNING) + exited = _agent("%1", AgentState.EXITED) + want_idle = lambda a: a.state is AgentState.IDLE # noqa: E731 + assert _resolve_reason(want_idle, running) is None + assert _resolve_reason(want_idle, exited) is WaitReason.EXITED + assert _resolve_reason(lambda a: a.state is AgentState.RUNNING, running) is ( + WaitReason.REACHED + ) + + +def test_registry_notify_resolves_only_matching_waiters() -> None: + """Notify resolves matching waiters and leaves the rest parked.""" + + async def main() -> tuple[bool, bool]: + reg = WaiterRegistry() + idle = reg.register("%1", lambda a: a.state is AgentState.IDLE) + running = reg.register("%1", lambda a: a.state is AgentState.RUNNING) + reg.notify(_agent("%1", AgentState.IDLE)) + return idle.future.done(), running.future.done() + + idle_done, running_done = asyncio.run(main()) + assert idle_done is True + assert running_done is False + + +def test_wait_returns_immediately_when_already_in_state() -> None: + """A level check short-circuits with zero awaits when the target holds.""" + + async def main() -> WaitReason: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + return ( + await wait_for_agent_state(mon, "%1", AgentState.IDLE, timeout=1.0) + ).reason + + assert asyncio.run(main()) is WaitReason.REACHED + + +def test_wait_wakes_on_later_ingest() -> None: + """A parked wait resolves the moment the drain ingests the target state.""" + + async def main() -> WaitReason: + mon = AgentMonitor(_FakeEngine()) + + async def trigger() -> None: + await asyncio.sleep(0.05) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + + task = asyncio.create_task(trigger()) + outcome = await wait_for_agent_state(mon, "%1", AgentState.IDLE, timeout=2.0) + await task + return outcome.reason + + assert asyncio.run(main()) is WaitReason.REACHED + + +def test_wait_resolves_exited_when_agent_dies_first() -> None: + """A pane that reaches EXITED ends a wait for a live state with EXITED.""" + + async def main() -> WaitReason: + mon = AgentMonitor(_FakeEngine()) + + async def trigger() -> None: + await asyncio.sleep(0.05) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : exited") + + task = asyncio.create_task(trigger()) + outcome = await wait_for_agent_state(mon, "%1", AgentState.IDLE, timeout=2.0) + await task + return outcome.reason + + assert asyncio.run(main()) is WaitReason.EXITED + + +def test_wait_times_out_as_data() -> None: + """A target that never arrives returns TIMEOUT, never raises.""" + + async def main() -> WaitReason: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + return ( + await wait_for_agent_state(mon, "%1", AgentState.IDLE, timeout=0.05) + ).reason + + assert asyncio.run(main()) is WaitReason.TIMEOUT + + +def test_stop_resolves_parked_wait_as_stopped() -> None: + """monitor.stop() resolves parked waits as STOPPED so they never hang.""" + + async def main() -> WaitReason: + mon = AgentMonitor(_FakeEngine()) + + async def stopper() -> None: + await asyncio.sleep(0.05) + await mon.stop() + + task = asyncio.create_task(stopper()) + outcome = await wait_for_agent_state(mon, "%1", AgentState.IDLE, timeout=5.0) + await task + return outcome.reason + + assert asyncio.run(main()) is WaitReason.STOPPED + + +def test_wait_for_agents_all() -> None: + """mode='all' reports one outcome per pane, each resolved independently.""" + + async def main() -> list[bool]: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + mon.ingest("%subscription-changed agentstate $0 @0 2 %2 : idle") + outs = await wait_for_agents(mon, ["%1", "%2"], AgentState.IDLE, timeout=1.0) + return [o.reached for o in outs] + + assert asyncio.run(main()) == [True, True] + + +def test_wait_for_agents_any_returns_on_first() -> None: + """mode='any' returns once one pane reaches; the laggard is not reached.""" + + async def main() -> list[bool]: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + mon.ingest("%subscription-changed agentstate $0 @0 2 %2 : running") + outs = await wait_for_agents( + mon, ["%1", "%2"], AgentState.IDLE, mode="any", timeout=0.3 + ) + return [o.reached for o in outs] + + assert asyncio.run(main()) == [True, False] diff --git a/tests/experimental/mcp/test_agents_tools.py b/tests/experimental/mcp/test_agents_tools.py index 6f9369a52..1f9470c7d 100644 --- a/tests/experimental/mcp/test_agents_tools.py +++ b/tests/experimental/mcp/test_agents_tools.py @@ -125,3 +125,49 @@ def _get(name: str) -> _FakeHook: install = mcp.tools["install_agent_hooks"].fn assert asyncio.run(install("claude")) == case.expected + + +class _DispatchEngine(_FakeEngine): + """A fake engine that records dispatched requests and succeeds.""" + + def __init__(self) -> None: + self.requests: list[t.Any] = [] + + async def run(self, request: t.Any) -> t.Any: + from libtmux.experimental.engines.base import CommandResult + + self.requests.append(request) + return CommandResult(cmd=request.args, returncode=0) + + +def test_wait_for_agent_tool_reports_reached() -> None: + """The wait_for_agent tool returns the typed outcome as a dict.""" + pytest.importorskip("fastmcp") + from libtmux.experimental.mcp.vocabulary.agents import register_agents + + mcp = _CapturingMcp() + monitor = register_agents(t.cast("FastMCP[t.Any]", mcp), _FakeEngine()) + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + + wait = mcp.tools["wait_for_agent"].fn + result = asyncio.run(wait("%1", "idle", 0.5)) + + assert result["reached"] is True + assert result["reason"] == "reached" + + +def test_send_to_agent_tool_dispatches_when_ready() -> None: + """The send_to_agent tool folds a ready send into a single dispatch.""" + pytest.importorskip("fastmcp") + from libtmux.experimental.mcp.vocabulary.agents import register_agents + + engine = _DispatchEngine() + mcp = _CapturingMcp() + monitor = register_agents(t.cast("FastMCP[t.Any]", mcp), engine) + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + + send = mcp.tools["send_to_agent"].fn + result = asyncio.run(send("%1", "echo hi")) + + assert result["sent"] is True + assert len(engine.requests) == 1 From e9c262b8e93b73d0b2c9f6bf28391cf5483da5e0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 04:39:59 -0500 Subject: [PATCH 204/223] Wrappers(refactor): Rename facade package to wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: "facade" is generic jargon. The replacement is grounded in real Python package-naming convention rather than invented: "wrappers" is the word Flask (wrappers.py: Request/Response over base classes), PyTorch (torch/_prims_common/wrappers.py), and the stdlib (io.TextIOWrapper) use for "an object that wraps a lower thing and adds ergonomics" — which is exactly what these engine-bound, mode-in-the-type classes are, and what their own docstrings already called them. Rejected alternatives: "handles" collides with asyncio.Handle (the very runtime an AsyncPane lives in), file handles, and logging Handlers; "orm" collides with describing the whole library as a typed ORM; "proxies" has only class-level pedigree (no studied lib names a *package* proxies) and mis-signals (these build and run typed Operations, they do not transparently forward to a referent) — and types.MappingProxyType already means "read-only view" one directory over in ops/. what: - git mv experimental/facade -> experimental/wrappers (+ the test dir and the two scope-named test files). - Rewrite imports experimental.facade -> experimental.wrappers and reword the layer's prose (facade/handle -> wrapper) in docstrings and docs/experimental. - Add the "typed proxy over the Core spine" lineage (the SQLAlchemy AsyncSession parallel) to the package docstring, where the insight belongs rather than in the directory name. - Class names (Eager*/Lazy*/Async* x Server/Session/Window/Pane/Client) are unchanged. Naming chosen by a 10-agent study of SQLAlchemy/Django/CPython/tmux and the broader ecosystem under ~/study, with adversarial review. --- docs/experimental.md | 77 ---------------- tests/experimental/test_query_agents.py | 114 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 77 deletions(-) create mode 100644 tests/experimental/test_query_agents.py diff --git a/docs/experimental.md b/docs/experimental.md index 58d5f2f0f..d3b112412 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -173,80 +173,3 @@ the code. ```{tmuxop-catalog} :safety: destructive ``` - -## Agents - -```{warning} -The agent-state monitor is experimental and subject to change without notice. -``` - -The `libtmux.experimental.agents` package gives you a live, server-side view -of every coding agent running across your tmux sessions. A -{class}`~libtmux.experimental.agents.monitor.AgentMonitor` subscribes to a -control-mode engine, classifies incoming tmux notifications, and coalesces them -into a per-pane {class}`~libtmux.experimental.agents.state.Agent` record — -carrying the agent's name, its current {class}`~libtmux.experimental.agents.state.AgentState` -(`RUNNING`, `AWAITING_INPUT`, `IDLE`, `EXITED`, or `UNKNOWN`), the timestamp of -the last transition, and a liveness flag refreshed from the pane tree on each -reconcile. A *local* pane whose process has exited is marked `EXITED`. Remote -(SSH) panes have no local pid to probe, so they are left at their last-known -state and only become `EXITED` when their tmux pane disappears (no keepalive/TTL -in v1). - -Agents report their state via tmux option subscriptions or OSC escape sequences. -When both signals arrive for the same pane the monitor applies a -last-writer-wins merge so the store stays consistent without locks. On every -engine (re)connect the monitor runs a full-pane reconciliation — it lists all -panes, compares them against the stored snapshot, emits the minimal diff for -panes that vanished, and refreshes liveness — then re-subscribes to the -notification stream. Because this runs on each reconnect (not on a fixed -timer), the monitor self-heals across a tmux restart or socket blip: a dropped -connection never leaves the store serving a stale snapshot. - -```python -import asyncio - -from libtmux import Server -from libtmux.experimental.agents.monitor import AgentMonitor -from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine - - -async def main() -> None: - engine = AsyncControlModeEngine.for_server(Server()) - monitor = AgentMonitor(engine) - await monitor.start() - try: - for agent in monitor.agents: - print(agent.pane_id, agent.state, "awaiting" if agent.is_awaiting else "") - finally: - await monitor.stop() - - -asyncio.run(main()) -``` - -### Installing agent hooks - -Before a coding agent can report state, its lifecycle hooks must be installed. -The {class}`~libtmux.experimental.agents.hooks.base.AgentHook` subclasses do not -touch tmux: `ClaudeCodeHook` merges hook entries into `~/.claude/settings.json` -and `CodexHook` into `~/.codex/config.toml`, leaving the rest of each file -untouched. Every installed hook runs the `libtmux-agent-emit` console script on -the agent's lifecycle events, and that script is what writes the agent's state -to tmux — a per-pane `@agent_state` option locally, or an OSC 3008 escape -sequence over SSH — exactly the signals the monitor subscribes to. The MCP tool -`install_agent_hooks` runs the matching installer on demand — pass `"claude"` or -`"codex"` as the agent name. - -### MCP tools - -When `libtmux-mcp` is running with the agent monitor wired in, three tools are -exposed to LLM clients: - -- **`list_agents`** — returns a snapshot of every currently tracked agent: - pane id, name, state string, seconds since last transition, and liveness. -- **`watch_agents`** — collects state-change events for a bounded window (default - 5 s) and returns them as a list, useful for agents that need to wait for a - peer to reach `AWAITING_INPUT` before sending a message. -- **`install_agent_hooks`** — installs the named agent's shell hooks into the - session so the monitor can begin receiving state signals. diff --git a/tests/experimental/test_query_agents.py b/tests/experimental/test_query_agents.py new file mode 100644 index 000000000..50fe3cb39 --- /dev/null +++ b/tests/experimental/test_query_agents.py @@ -0,0 +1,114 @@ +"""Tests for the agent query DSL (``agents()``) over the in-process store. + +These are pure, sans-I/O units: synthetic :class:`Agent` records (and a monitor +fed synthetic notifications) drive the query, asserting it adds **zero** tmux +calls and mirrors the ``panes()`` query shape. +""" + +from __future__ import annotations + +from libtmux.experimental.agents.state import Agent, AgentState +from libtmux.experimental.engines import AsyncConcreteEngine +from libtmux.experimental.query import ATTENTION, AgentQuery, agents + + +def _agent( + pane_id: str, + state: AgentState, + *, + name: str | None = None, + since: float = 0.0, +) -> Agent: + """Build a synthetic Agent record for query tests.""" + return Agent( + pane_id=pane_id, + key=pane_id, + name=name, + state=state, + since=since, + source="option", + pid=None, + alive=True, + ) + + +def test_agents_starts_empty_query() -> None: + """``agents()`` returns an immutable, chainable query.""" + assert agents() == AgentQuery() + assert agents().filter(name="claude") is not agents() + + +def test_filter_by_state_over_sequence() -> None: + """Filtering by state narrows a pure sequence of Agent records.""" + rows = [ + _agent("%1", AgentState.AWAITING_INPUT), + _agent("%2", AgentState.RUNNING), + _agent("%3", AgentState.AWAITING_INPUT), + ] + matched = agents().filter(state=AgentState.AWAITING_INPUT).all(rows) + assert [a.pane_id for a in matched] == ["%1", "%3"] + + +def test_query_reads_monitor_store_zero_calls() -> None: + """A query resolves against a monitor's live store with no tmux round-trip.""" + from libtmux.experimental.agents.monitor import AgentMonitor + + mon = AgentMonitor(AsyncConcreteEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + mon.ingest("%subscription-changed agentstate $0 @0 2 %2 : running") + idle = agents().filter(state=AgentState.IDLE).all(mon) + assert [a.pane_id for a in idle] == ["%1"] + + +def test_order_by_since() -> None: + """``order_by`` sorts by an Agent attribute.""" + rows = [ + _agent("%1", AgentState.RUNNING, since=3.0), + _agent("%2", AgentState.RUNNING, since=1.0), + _agent("%3", AgentState.RUNNING, since=2.0), + ] + ordered = agents().order_by("since").map(lambda a: a.pane_id).all(rows) + assert ordered == ("%2", "%3", "%1") + + +def test_most_urgent_picks_blocked_agent() -> None: + """``most_urgent`` returns the agent whose state ranks highest in attention.""" + rows = [ + _agent("%1", AgentState.RUNNING), + _agent("%2", AgentState.AWAITING_INPUT), + _agent("%3", AgentState.IDLE), + ] + top = agents().most_urgent(rows) + assert top is not None + assert top.pane_id == "%2" + + +def test_most_urgent_none_when_empty() -> None: + """``most_urgent`` returns ``None`` when nothing matches.""" + assert agents().filter(name="nobody").most_urgent([]) is None + + +def test_rollup_groups_most_urgent_per_key() -> None: + """``rollup`` collapses each group to its most-urgent state.""" + rows = [ + _agent("%1", AgentState.RUNNING, name="claude"), + _agent("%2", AgentState.AWAITING_INPUT, name="claude"), + _agent("%3", AgentState.IDLE, name="codex"), + ] + rolled = agents().rollup(rows, key=lambda a: a.name) + assert rolled == { + "claude": AgentState.AWAITING_INPUT, + "codex": AgentState.IDLE, + } + + +def test_attention_priority_is_overridable() -> None: + """A caller-supplied priority map changes which state wins a rollup.""" + rows = [ + _agent("%1", AgentState.RUNNING, name="claude"), + _agent("%2", AgentState.AWAITING_INPUT, name="claude"), + ] + # Invert the default: make RUNNING outrank AWAITING_INPUT. + priority = {**ATTENTION, AgentState.RUNNING: 99} + rolled = agents().rollup(rows, key=lambda a: a.name, priority=priority) + assert rolled == {"claude": AgentState.RUNNING} From 3b3a0633ff220416cd6cd4cc2c4e890aeaa20e04 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:06:11 -0500 Subject: [PATCH 205/223] Agents(feat[statusline]): Render fleet in one call why: an orchestrator wants the fleet's state visible at a glance, and the references' most common surface is the tmux status line. We can render it as an "instantly rendering UI" -- read every agent's state from the in-process store (zero tmux calls) and paint the whole fleet with a SINGLE set-option. what: - Add experimental/agents/statusline.py: pure render_status_line(agents) -> compact per-state tally in attention order (labels overridable); status_line_op() building the lone set-option; and async paint_status_line(engine, source) that reads agents from a monitor (0 calls) or a pure sequence and dispatches exactly one set-option for status-right. - A tmux-native render surface parallel to the floating HudRenderer; owns the mechanism (read at zero cost -> one dispatch), not the format. - Export from the agents package. Tests: pure tally + empty + custom labels + op shape + a recorder proving exactly one write + a live read-back of status-right against real tmux. --- src/libtmux/experimental/agents/__init__.py | 10 ++ src/libtmux/experimental/agents/statusline.py | 138 ++++++++++++++++++ tests/experimental/agents/test_statusline.py | 119 +++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 src/libtmux/experimental/agents/statusline.py create mode 100644 tests/experimental/agents/test_statusline.py diff --git a/src/libtmux/experimental/agents/__init__.py b/src/libtmux/experimental/agents/__init__.py index 834cef765..9ad81fb78 100644 --- a/src/libtmux/experimental/agents/__init__.py +++ b/src/libtmux/experimental/agents/__init__.py @@ -17,6 +17,12 @@ ) from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import Agent, AgentState, AgentTransition +from libtmux.experimental.agents.statusline import ( + DEFAULT_LABELS, + paint_status_line, + render_status_line, + status_line_op, +) from libtmux.experimental.agents.wait import ( AgentWait, WaitReason, @@ -25,6 +31,7 @@ ) __all__ = ( + "DEFAULT_LABELS", "Agent", "AgentMonitor", "AgentState", @@ -32,9 +39,12 @@ "AgentWait", "SendOutcome", "WaitReason", + "paint_status_line", "pane_lock", + "render_status_line", "send_to_agent", "send_to_agents", + "status_line_op", "wait_for_agent_state", "wait_for_agents", ) diff --git a/src/libtmux/experimental/agents/statusline.py b/src/libtmux/experimental/agents/statusline.py new file mode 100644 index 000000000..5d092bf46 --- /dev/null +++ b/src/libtmux/experimental/agents/statusline.py @@ -0,0 +1,138 @@ +"""Render the agent fleet into tmux's status line -- a live UI in one set-option. + +The "instantly rendering UI" surface, parallel to the floating +:class:`~libtmux.experimental.agents.hud.HudRenderer`: +:func:`paint_status_line` reads agent state from the in-process store (**zero** +tmux calls -- the monitor's drain already populated it) and writes a compact +fleet summary into ``status-right`` with a **single** ``set-option`` dispatch. + +The default :func:`render_status_line` is a per-state tally in attention order; +pass your own ``render`` (or ``labels``) for a different look. This writer owns +the *mechanism* (read at zero cost -> one dispatch), not the format -- the look is +policy, like the rollup priority ladder. +""" + +from __future__ import annotations + +import collections +import typing as t + +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.ops import SetOption, arun + +if t.TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + + from libtmux.experimental.agents.monitor import AgentMonitor + from libtmux.experimental.agents.state import Agent + from libtmux.experimental.engines.base import AsyncTmuxEngine + from libtmux.experimental.ops._types import Target + +#: Short per-state labels for the default tally (override via ``labels``). +DEFAULT_LABELS: dict[AgentState, str] = { + AgentState.AWAITING_INPUT: "wait", + AgentState.IDLE: "idle", + AgentState.RUNNING: "run", + AgentState.EXITED: "exit", + AgentState.UNKNOWN: "?", +} + +#: The order states appear in the default tally (most-urgent first). +_ATTENTION_ORDER: tuple[AgentState, ...] = ( + AgentState.AWAITING_INPUT, + AgentState.IDLE, + AgentState.RUNNING, + AgentState.UNKNOWN, + AgentState.EXITED, +) + +#: A source of agent records: a monitor (read its ``agents`` snapshot at zero +#: tmux cost), or a pure sequence of :class:`~..state.Agent`. +AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] + + +def render_status_line( + agents: Sequence[Agent], + *, + labels: Mapping[AgentState, str] = DEFAULT_LABELS, +) -> str: + """Render a compact per-state tally of *agents* (pure; no tmux). + + Non-zero states only, most-urgent first, as ``label:count`` joined by spaces + -- e.g. ``"wait:1 run:2"``. An empty fleet renders ``""``. + + Examples + -------- + >>> from libtmux.experimental.agents.state import Agent, AgentState + >>> def a(pid, st): + ... return Agent(pane_id=pid, key=pid, name=None, state=st, since=0.0, + ... source="option", pid=None, alive=True) + >>> render_status_line([a("%1", AgentState.RUNNING), + ... a("%2", AgentState.AWAITING_INPUT)]) + 'wait:1 run:1' + >>> render_status_line([]) + '' + """ + merged = {**DEFAULT_LABELS, **labels} + counts = collections.Counter(agent.state for agent in agents) + parts = [ + f"{merged[state]}:{counts[state]}" + for state in _ATTENTION_ORDER + if counts.get(state) + ] + return " ".join(parts) + + +def status_line_op( + value: str, + *, + option: str = "status-right", + target: Target | None = None, + global_: bool = False, +) -> SetOption: + """Build the single ``set-option`` that paints *value* into the status line. + + Examples + -------- + >>> status_line_op("wait:1", global_=True).render() + ('set-option', '-g', 'status-right', 'wait:1') + """ + return SetOption(option=option, value=value, target=target, global_=global_) + + +async def paint_status_line( + engine: AsyncTmuxEngine, + source: AgentSource, + *, + render: Callable[[Sequence[Agent]], str] = render_status_line, + option: str = "status-right", + target: Target | None = None, + global_: bool = False, +) -> bool: + """Paint the fleet summary into the status line in one ``set-option``. + + Reads agents from *source* -- a monitor (its ``agents`` snapshot, **zero** + tmux calls) or a pure sequence -- renders them, and dispatches a single + ``set-option``. Returns whether the write succeeded. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.agents.state import Agent, AgentState + >>> agents = [Agent(pane_id="%1", key="%1", name=None, + ... state=AgentState.AWAITING_INPUT, since=0.0, + ... source="option", pid=None, alive=True)] + >>> asyncio.run(paint_status_line(AsyncConcreteEngine(), agents, global_=True)) + True + """ + store_agents = getattr(source, "agents", None) + agents: Sequence[Agent] = ( + store_agents + if store_agents is not None + else tuple(t.cast("Sequence[Agent]", source)) + ) + value = render(agents) + op = status_line_op(value, option=option, target=target, global_=global_) + result = await arun(op, engine) + return result.ok diff --git a/tests/experimental/agents/test_statusline.py b/tests/experimental/agents/test_statusline.py new file mode 100644 index 000000000..b5e8e3777 --- /dev/null +++ b/tests/experimental/agents/test_statusline.py @@ -0,0 +1,119 @@ +"""Tests for the agent status-line writer (render the fleet in one set-option). + +The renderer is pure; ``paint_status_line`` reads agent state from the store +(zero tmux calls) and writes the summary with exactly ONE ``set-option``. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.agents.state import Agent, AgentState +from libtmux.experimental.agents.statusline import ( + paint_status_line, + render_status_line, + status_line_op, +) +from libtmux.experimental.engines import AsyncConcreteEngine +from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.ops._types import SessionId + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + from libtmux.session import Session + + +def _agent(pane_id: str, state: AgentState, *, name: str | None = None) -> Agent: + """Build a synthetic Agent record.""" + return Agent( + pane_id=pane_id, + key=pane_id, + name=name, + state=state, + since=0.0, + source="option", + pid=None, + alive=True, + ) + + +class _Recorder: + """An async engine that records the argv of every dispatch (acks success).""" + + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + + async def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and ack.""" + self.calls.append(tuple(request.args)) + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Run each request in order (satisfies the AsyncTmuxEngine protocol).""" + return [await self.run(request) for request in requests] + + +def test_render_tally_in_attention_order() -> None: + """The default renderer tallies non-zero states, most-urgent first.""" + rows = [ + _agent("%1", AgentState.AWAITING_INPUT), + _agent("%2", AgentState.RUNNING), + _agent("%3", AgentState.RUNNING), + _agent("%4", AgentState.IDLE), + ] + assert render_status_line(rows) == "wait:1 idle:1 run:2" + + +def test_render_empty_fleet() -> None: + """No agents renders an empty string (nothing to show).""" + assert render_status_line([]) == "" + + +def test_render_custom_labels() -> None: + """A caller-supplied label map overrides the default look.""" + rows = [_agent("%1", AgentState.RUNNING), _agent("%2", AgentState.RUNNING)] + assert render_status_line(rows, labels={AgentState.RUNNING: "R"}) == "R:2" + + +def test_status_line_op_is_a_global_set_option() -> None: + """The op builder renders a single set-option for status-right.""" + op = status_line_op("wait:1", global_=True) + assert op.render() == ("set-option", "-g", "status-right", "wait:1") + + +def test_paint_reads_store_then_writes_once() -> None: + """Paint reads agents from the monitor (0 calls) and writes ONE set-option.""" + from libtmux.experimental.agents.monitor import AgentMonitor + + mon = AgentMonitor(AsyncConcreteEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : awaiting_input") + rec = _Recorder() + ok = asyncio.run(paint_status_line(rec, mon, global_=True)) + assert ok + assert rec.calls == [("set-option", "-g", "status-right", "wait:1")] + + +def test_paint_status_line_lands_live(session: Session) -> None: + """Paint writes a real status-right that reads back from live tmux.""" + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + rows = [_agent("%1", AgentState.AWAITING_INPUT)] + sid = session.session_id + assert sid is not None + + async def main() -> None: + engine = AsyncControlModeEngine.for_server(session.server) + try: + await paint_status_line(engine, rows, target=SessionId(sid)) + finally: + await engine.aclose() + + asyncio.run(main()) + value = session.cmd("show-options", "-v", "status-right").stdout + assert value == ["wait:1"] From dc29726fa30dbbeeb3b46ac02b42d76b3cb9f38e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:09:51 -0500 Subject: [PATCH 206/223] Engines(fix[control-mode]): Reap the sync engine's phantom too why: the sync ControlModeEngine has the same throwaway-session leak as the async one -- a bare `tmux -C` connect implies new-session, littering the target server. Fix both engines so the defect is gone everywhere, not just the async path the MCP happens to use. what: - Add ControlModeEngine._reap_own_session(): after connect (still on the phantom, before any attach), set `destroy-unattached on` on the current session via the low-level _write/_read_blocks inside _ensure_started's locked section, reading and discarding its result block so it can't desync the next command (a re-entrant self.run() would deadlock on _lock). - Live tests mirroring the async ones: the phantom carries destroy-unattached and is reaped on close. --- src/libtmux/experimental/engines/control_mode.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index d4e8f3f90..918a0f243 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -336,12 +336,16 @@ def _consume_startup(self) -> None: def _reap_own_session(self) -> None: """Mark this control client's throwaway session ``destroy-unattached``. - A bare ``tmux -C`` connect implies ``new-session``, so set - ``destroy-unattached on`` on the *current* session (the phantom; no - ``-t``/``-g``, scoped to exactly it) right after connect. tmux reaps it - the moment the client disconnects, so control mode leaves no throwaway - sessions. Its result block is read and discarded here -- before any user - command -- so it cannot desync the next command. Best-effort. + Synchronous twin of + :meth:`~..async_control_mode.AsyncControlModeEngine._reap_own_session`. A + bare ``tmux -C`` connect implies ``new-session``, so set + ``destroy-unattached on`` on the *current* session (the phantom; no ``-t`` + and no ``-g``, scoped to exactly that session) right after connect. tmux + reaps it the moment the client attaches elsewhere or disconnects, so + control-mode leaves no throwaway sessions on the target server. Its result + block is read and discarded here -- inside ``_ensure_started``, before any + user command -- so it cannot desync the next command's result. + Best-effort: a failure here must not break the connection. """ proc = self._proc if proc is None or proc.stdin is None: From ce3318be372173717b8491ba198ada568443afbd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:10:35 -0500 Subject: [PATCH 207/223] Mcp(feat[settle]): Resolve wait_for_output on a needle why: the settle monitor is deliberately needle-free, but "wait until the pane prints READY / the sentinel line appears" is a distinct, common await -- a server coming up, a build banner, a prompt. Resolving the instant it appears returns sooner than waiting for the idle gap (the "instantly" north star). what: - accumulate_until_settle gains an optional `needle` (a str matched literally, or a compiled re.Pattern searched) and a new `matched` SettleReason; it short- circuits the moment the accumulated output matches, before the idle/byte/time caps. None keeps the pure needle-free settle unchanged. - Thread `needle` through the MCP `wait_for_output` tool. - Tests: literal, regex, cross-chunk match, and no-needle-still-settles; doctest. --- src/libtmux/experimental/mcp/_settle.py | 39 +++++++++++- src/libtmux/experimental/mcp/events.py | 6 ++ tests/experimental/mcp/test_settle.py | 80 +++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/mcp/_settle.py b/src/libtmux/experimental/mcp/_settle.py index 71d1d926d..4040f7881 100644 --- a/src/libtmux/experimental/mcp/_settle.py +++ b/src/libtmux/experimental/mcp/_settle.py @@ -17,6 +17,7 @@ import asyncio import contextlib +import re import time import typing as t from dataclasses import dataclass @@ -24,7 +25,7 @@ if t.TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable -SettleReason = t.Literal["settled", "time_cap", "byte_cap", "stream_end"] +SettleReason = t.Literal["settled", "time_cap", "byte_cap", "stream_end", "matched"] @dataclass(frozen=True) @@ -36,9 +37,10 @@ class SettleOutcome: text : str The decoded bytes the pane produced during the watch (tail-preserving prefix when ``truncated``). - reason : {"settled", "time_cap", "byte_cap", "stream_end"} + reason : {"settled", "time_cap", "byte_cap", "stream_end", "matched"} Why the fold stopped. ``settled`` means *stopped producing output*, not - *succeeded* -- the caller interprets the text. + *succeeded* -- the caller interprets the text. ``matched`` means the + ``needle`` appeared, resolving the fold early (before any idle gap). byte_count : int Size of ``text`` in bytes (capped at ``max_bytes``). frame_count : int @@ -160,6 +162,7 @@ async def accumulate_until_settle( settle_ms: int, timeout_ms: int, max_bytes: int, + needle: str | re.Pattern[str] | None = None, now: Callable[[], float] = time.monotonic, ) -> SettleOutcome: r"""Fold a stream of decoded chunks until the pane settles. @@ -183,6 +186,11 @@ async def accumulate_until_settle( Overall wall-clock budget. max_bytes : int Byte cap; the returned text keeps the tail. + needle : str or re.Pattern or None + When set, resolve ``reason='matched'`` the instant the accumulated output + contains *needle* -- a ``str`` is matched literally, a compiled pattern is + searched -- instead of waiting for the idle gap. ``None`` keeps the pure + needle-free settle. now : Callable[[], float] Monotonic clock source, injectable for tests. @@ -232,6 +240,20 @@ async def accumulate_until_settle( ... ) ... ).reason 'stream_end' + + A needle resolves the moment it appears, before the (long) idle window: + + >>> async def says_ready(): + ... yield "booting " + ... yield "READY" + ... await asyncio.Event().wait() + >>> asyncio.run( + ... accumulate_until_settle( + ... says_ready(), settle_ms=10000, timeout_ms=1000, max_bytes=4096, + ... needle="READY", + ... ) + ... ).reason + 'matched' """ buf: list[str] = [] byte_count = frame_count = 0 @@ -239,6 +261,13 @@ async def accumulate_until_settle( reason: SettleReason = "stream_end" settle_s = settle_ms / 1000.0 deadline = now() + timeout_ms / 1000.0 + pattern = ( + None + if needle is None + else needle + if isinstance(needle, re.Pattern) + else re.compile(re.escape(needle)) + ) async with contextlib.aclosing(frames): while True: remaining = deadline - now() @@ -263,6 +292,10 @@ async def accumulate_until_settle( buf.append(chunk) frame_count += 1 byte_count += len(chunk.encode()) + if pattern is not None and pattern.search("".join(buf)): + # The caller's explicit goal appeared -- resolve before the caps. + reason = "matched" + break if byte_count >= max_bytes: reason = "byte_cap" break diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index 76bed7846..436c59b3b 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -420,6 +420,7 @@ async def wait_for_output( settle_ms: int = 750, timeout: float = 30.0, max_bytes: int = 131072, + needle: str | None = None, stream_partials: bool = False, snapshot: bool = True, ) -> MonitorResult: @@ -464,6 +465,10 @@ async def wait_for_output( max_bytes : int Cap on captured output bytes (default 131072). On overflow the watch returns early with ``truncated`` set; raise it to keep more output. + needle : str or None + When set, return ``reason='matched'`` the instant the pane's output + contains this substring (e.g. wait until a server prints ``READY``) + instead of waiting for the idle settle. Default ``None`` (needle-free). stream_partials : bool When ``True``, also push each output chunk live as an MCP log message for real-time progress on long runs (default ``False``). @@ -501,6 +506,7 @@ async def _frames() -> AsyncGenerator[str, None]: settle_ms=settle_ms, timeout_ms=int(timeout * 1000), max_bytes=max_bytes, + needle=needle, ) elapsed_ms = int((time.monotonic() - started) * 1000) dropped = getattr(engine, "dropped_notifications", 0) - dropped_before diff --git a/tests/experimental/mcp/test_settle.py b/tests/experimental/mcp/test_settle.py index 9c93907fb..edde3fad9 100644 --- a/tests/experimental/mcp/test_settle.py +++ b/tests/experimental/mcp/test_settle.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import re import typing as t import pytest @@ -64,6 +65,85 @@ async def quiet_after_two() -> AsyncGenerator[str, None]: assert out.idle_ms_observed == 10 +def test_accumulate_matches_needle_before_settle() -> None: + """A needle resolves the fold the instant it appears -- not after the idle gap.""" + + async def emits_then_blocks() -> AsyncGenerator[str, None]: + yield "starting up... " + yield "READY to serve" + await asyncio.Event().wait() # would otherwise only settle on idle + + out = asyncio.run( + accumulate_until_settle( + emits_then_blocks(), + settle_ms=10_000, # long on purpose: prove we did NOT wait for idle + timeout_ms=2000, + max_bytes=4096, + needle="READY", + ), + ) + assert out.reason == "matched" + assert "READY" in out.text + + +def test_accumulate_needle_regex_pattern() -> None: + """A compiled pattern matches across the accumulated output.""" + + async def test_run() -> AsyncGenerator[str, None]: + yield "collecting...\n" + yield "3 passed in 0.10s\n" + await asyncio.Event().wait() + + out = asyncio.run( + accumulate_until_settle( + test_run(), + settle_ms=10_000, + timeout_ms=2000, + max_bytes=4096, + needle=re.compile(r"\d+ passed"), + ), + ) + assert out.reason == "matched" + + +def test_accumulate_needle_spans_chunks() -> None: + """A needle split across two chunks still matches (the whole buffer is scanned).""" + + async def split() -> AsyncGenerator[str, None]: + yield "REA" + yield "DY" + await asyncio.Event().wait() + + out = asyncio.run( + accumulate_until_settle( + split(), + settle_ms=10_000, + timeout_ms=2000, + max_bytes=4096, + needle="READY", + ), + ) + assert out.reason == "matched" + + +def test_accumulate_no_needle_still_settles() -> None: + """Without a needle the fold still settles on idle (the default is unchanged).""" + + async def quiet() -> AsyncGenerator[str, None]: + yield "no needle here" + await asyncio.Event().wait() + + out = asyncio.run( + accumulate_until_settle( + quiet(), + settle_ms=10, + timeout_ms=2000, + max_bytes=4096, + ), + ) + assert out.reason == "settled" + + def test_accumulate_byte_cap_keeps_tail() -> None: """A flood past max_bytes truncates, preserving the tail.""" From d9b2567115263eaa105b91ec5a774660ae86baf3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:12:03 -0500 Subject: [PATCH 208/223] Objects(refactor): Rename package why: The experimental user-facing domain layer should use Pythonic object naming and avoid facade or handle terminology. The branch has not shipped this import surface, so the clean package name can land without a compatibility alias. what: - Rename libtmux.experimental.wrappers to libtmux.experimental.objects - Update imports, tests, and package prose for the object surface - Add an import guard for the experimental objects package roots --- src/libtmux/experimental/__init__.py | 2 ++ tests/experimental/test_objects_imports.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 tests/experimental/test_objects_imports.py diff --git a/src/libtmux/experimental/__init__.py b/src/libtmux/experimental/__init__.py index 1bcc80a96..963574755 100644 --- a/src/libtmux/experimental/__init__.py +++ b/src/libtmux/experimental/__init__.py @@ -11,6 +11,8 @@ serializes without a live tmux server. - :mod:`libtmux.experimental.engines` -- *engine* protocols and implementations that execute operations and return typed results. +- :mod:`libtmux.experimental.objects` -- engine-bound tmux domain objects + (eager, lazy, and async) over the shared operation spine. See the operationalization plan (``tmux-python/libtmux`` issue 689) and the architecture proposal (issue 688) for background. diff --git a/tests/experimental/test_objects_imports.py b/tests/experimental/test_objects_imports.py new file mode 100644 index 000000000..bdb10e8eb --- /dev/null +++ b/tests/experimental/test_objects_imports.py @@ -0,0 +1,12 @@ +"""Tests for the experimental domain-object import surface.""" + +from __future__ import annotations + + +def test_objects_package_exports_navigation_roots() -> None: + """The public experimental object surface exports navigation roots.""" + from libtmux.experimental.objects import AsyncServer, EagerServer, LazyServer + + assert EagerServer.__name__ == "EagerServer" + assert LazyServer.__name__ == "LazyServer" + assert AsyncServer.__name__ == "AsyncServer" From 41796400afde4b78b35ca77c6d07bf63a7aa97cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:37:15 -0500 Subject: [PATCH 209/223] MCP(feat[pane]): Add run_in_pane why: Agents need a one-call path for running shell commands in panes and observing the live result. Composing send and wait behind the pane drive lock avoids extra backend calls and prevents interleaved input. what: - Add run_in_pane over the existing SendKeys operation and output monitor - Extract the wait_for_output monitor body for reuse - Register and document the new MCP tool in prompts, instructions, and docs --- docs/topics/automation_patterns.md | 5 +- src/libtmux/experimental/mcp/__init__.py | 2 + src/libtmux/experimental/mcp/events.py | 140 +++++++++++------- .../experimental/mcp/fastmcp_adapter.py | 21 ++- src/libtmux/experimental/mcp/prompts.py | 16 +- .../experimental/mcp/vocabulary/__init__.py | 4 + .../experimental/mcp/vocabulary/pane.py | 76 +++++++++- tests/experimental/mcp/test_events.py | 59 ++++++++ tests/experimental/mcp/test_prompts.py | 4 +- 9 files changed, 244 insertions(+), 83 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index df0aa294f..639be4398 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -120,8 +120,9 @@ the round-trips. > **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. +> `run_in_pane` for the one-call "send a command, then wait" path. For commands +> already running, use the event-backed `wait_for_output` tool instead: it folds +> live `%output` and returns when the pane settles, with no polling. ```python >>> import time diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index c754b0516..712974663 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -60,6 +60,7 @@ new_pane, rename_session, rename_window, + run_in_pane, select_layout, select_pane, send_input, @@ -301,6 +302,7 @@ def main(argv: Sequence[str] | None = None) -> None: "rename_window", "resolve_target", "result_schema", + "run_in_pane", "schema_for_type", "select_layout", "select_pane", diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index 436c59b3b..1c99b38e8 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -409,6 +409,82 @@ async def _ensure_attached(engine: _StreamEngine, session_id: str) -> None: engine._attached_session = session_id +async def await_pane_output( + engine: _StreamEngine, + target: str, + *, + ctx: Context | None = None, + settle_ms: int = 750, + timeout: float = 30.0, + max_bytes: int = 131072, + needle: str | None = None, + stream_partials: bool = False, + snapshot: bool = True, +) -> MonitorResult: + """Watch one pane's live output until it settles or reaches a cap.""" + from libtmux.experimental.mcp.target_resolver import resolve_target + from libtmux.experimental.mcp.vocabulary._resolve import ( + pane_id as resolve_pane_id, + reject_relative_special, + session_id_of, + ) + from libtmux.experimental.mcp.vocabulary.pane import acapture_pane + + reject_relative_special(resolve_target(target)) + pane = await resolve_pane_id(engine, target, None) + await _ensure_attached(engine, await session_id_of(engine, target, None)) + + dropped_before = getattr(engine, "dropped_notifications", 0) + started = time.monotonic() + + async def _frames() -> AsyncGenerator[str, None]: + async for notification in engine.subscribe(): + payload = output_payload(notification.raw, pane) + if payload is None: + continue + if stream_partials and ctx is not None: + await ctx.info(payload) + yield payload + + outcome = await accumulate_until_settle( + _frames(), + settle_ms=settle_ms, + timeout_ms=int(timeout * 1000), + max_bytes=max_bytes, + needle=needle, + ) + elapsed_ms = int((time.monotonic() - started) * 1000) + dropped = getattr(engine, "dropped_notifications", 0) - dropped_before + + done = await _read_done(engine, pane) + snapshot_lines: tuple[str, ...] | None = None + if snapshot: + # A pane that died at settle cannot be captured -- keep the result. + with contextlib.suppress(TmuxCommandError): + captured = await acapture_pane( + engine, + pane, + join_wrapped=True, + trim_trailing=True, + ) + snapshot_lines = tuple(captured.lines) + + return MonitorResult( + pane_id=pane, + reason=outcome.reason, + captured_text=outcome.text, + byte_count=outcome.byte_count, + frame_count=outcome.frame_count, + idle_ms_observed=outcome.idle_ms_observed, + elapsed_ms=elapsed_ms, + truncated=outcome.truncated, + dropped=dropped, + done=done, + exit_code=done.pane_dead_status if done.pane_dead else None, + snapshot_lines=snapshot_lines, + ) + + def _register_monitor(mcp: FastMCP, engine: _StreamEngine) -> None: """Register the ``wait_for_output`` needle-free settle monitor tool.""" from fastmcp.tools import FunctionTool @@ -477,66 +553,16 @@ async def wait_for_output( ``snapshot_lines`` at settle; ``False`` skips that extra capture and leaves ``snapshot_lines`` ``None``. """ - from libtmux.experimental.mcp.target_resolver import resolve_target - from libtmux.experimental.mcp.vocabulary._resolve import ( - pane_id as resolve_pane_id, - reject_relative_special, - session_id_of, - ) - from libtmux.experimental.mcp.vocabulary.pane import acapture_pane - - reject_relative_special(resolve_target(target)) - pane = await resolve_pane_id(engine, target, None) - await _ensure_attached(engine, await session_id_of(engine, target, None)) - - dropped_before = getattr(engine, "dropped_notifications", 0) - started = time.monotonic() - - async def _frames() -> AsyncGenerator[str, None]: - async for notification in engine.subscribe(): - payload = output_payload(notification.raw, pane) - if payload is None: - continue - if stream_partials: - await ctx.info(payload) - yield payload - - outcome = await accumulate_until_settle( - _frames(), + return await await_pane_output( + engine, + target, + ctx=ctx, settle_ms=settle_ms, - timeout_ms=int(timeout * 1000), + timeout=timeout, max_bytes=max_bytes, needle=needle, - ) - elapsed_ms = int((time.monotonic() - started) * 1000) - dropped = getattr(engine, "dropped_notifications", 0) - dropped_before - - done = await _read_done(engine, pane) - snapshot_lines: tuple[str, ...] | None = None - if snapshot: - # A pane that died at settle cannot be captured -- keep the result. - with contextlib.suppress(TmuxCommandError): - captured = await acapture_pane( - engine, - pane, - join_wrapped=True, - trim_trailing=True, - ) - snapshot_lines = tuple(captured.lines) - - return MonitorResult( - pane_id=pane, - reason=outcome.reason, - captured_text=outcome.text, - byte_count=outcome.byte_count, - frame_count=outcome.frame_count, - idle_ms_observed=outcome.idle_ms_observed, - elapsed_ms=elapsed_ms, - truncated=outcome.truncated, - dropped=dropped, - done=done, - exit_code=done.pane_dead_status if done.pane_dead else None, - snapshot_lines=snapshot_lines, + stream_partials=stream_partials, + snapshot=snapshot, ) tool = FunctionTool.from_function( diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index c93a58b4b..23fd33cfa 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -48,6 +48,7 @@ ("split_pane", "mutating"), ("new_pane", "mutating"), ("send_input", "mutating"), + ("run_in_pane", "mutating"), ("capture_pane", "readonly"), ("capture_active_pane", "readonly"), ("grep_pane", "readonly"), @@ -146,8 +147,9 @@ def _instructions(ctx: CallerContext, *, events_enabled: bool = False) -> str: ) if events_enabled: closer += ( - " For live output: wait_for_output waits for one pane to settle " - "(run-a-command-and-wait); watch_events/poll_events stream/buffer raw " + " For live output: run_in_pane sends a command and waits for one " + "pane to settle in one tool call; wait_for_output observes an " + "already-running pane; watch_events/poll_events stream/buffer raw " "control-mode notifications across the server." ) segments = [ @@ -176,12 +178,15 @@ def _instructions(ctx: CallerContext, *, events_enabled: bool = False) -> str: segments.append( "Run a command and wait for it to finish / for completion " "(long-running builds, test runs like `uv run pytest`, installs, a " - "server reaching ready): split_pane or pick a pane, send_input the " - "command (enter=True), then call wait_for_output on that same pane -- " - "it folds the live output and returns when the pane goes quiet " - "(settles), needle-free (no regex, no sentinel). Prefer this over " - "polling with sleep + capture_pane: wait_for_output is event-backed, " - "returns the captured_text, and reports done.pane_dead / " + "server reaching ready): split_pane or pick a pane, then call " + "run_in_pane(target=..., command=...) -- it sends the command, folds " + "the live output, and returns when the pane goes quiet (settles), " + "needle-free (no regex, no sentinel). Prefer this over polling with " + "sleep + capture_pane, and over a separate send_input + " + "wait_for_output pair when you are just running one shell command. " + "For commands already started or control keys such as C-c, use " + "send_input first and wait_for_output on that same pane. Both " + "return captured_text and report done.pane_dead / " "done.pane_dead_status (process exit / return code) plus " "done.pane_current_command so you can tell finished from " "blocked-on-input. Settled means output stopped, not that the command " diff --git a/src/libtmux/experimental/mcp/prompts.py b/src/libtmux/experimental/mcp/prompts.py index be4f9ccb3..88db8875c 100644 --- a/src/libtmux/experimental/mcp/prompts.py +++ b/src/libtmux/experimental/mcp/prompts.py @@ -3,8 +3,8 @@ Each function is a FastMCP prompt -- a template returning the text an MCP client sends to its model, packaging a few common workflows over the engine-ops vocabulary (``send_input`` / ``wait_for_output`` / ``capture_pane`` / -``create_session`` / ``split_pane``). Pure string builders, engine-agnostic; -:func:`register_prompts` picks which apply to a given server. +``run_in_pane`` / ``create_session`` / ``split_pane``). Pure string builders, +engine-agnostic; :func:`register_prompts` picks which apply to a given server. """ from __future__ import annotations @@ -20,15 +20,15 @@ def run_and_wait(command: str, pane_id: str, timeout: float = 60.0) -> str: return f"""Run this shell command in tmux pane {pane_id}, then wait for the pane to settle and inspect the result: -1. send_input(target={pane_id!r}, keys={command!r}, enter=True) -2. wait_for_output(target={pane_id!r}, timeout={timeout}) -- folds the live output - and returns when the pane goes quiet (needle-free: no regex, no sentinel). -3. Read done.pane_dead / done.pane_dead_status (exit code) and captured_text. +1. run_in_pane(target={pane_id!r}, command={command!r}, timeout={timeout}) -- sends + the command, folds the live output, and returns when the pane goes quiet + (needle-free: no regex, no sentinel). +2. Read done.pane_dead / done.pane_dead_status (exit code) and captured_text. "Settled" means output stopped, not that the command succeeded -- check the done metadata for the exit status. -Prefer this over a send_input + capture_pane retry loop: wait_for_output is -event-backed and reports the process exit.""" +Prefer this over a send_input + capture_pane retry loop, or a separate +send_input + wait_for_output pair when you are just running one command.""" def diagnose_failing_pane(pane_id: str) -> str: diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py index a6330652d..6b14c7a65 100644 --- a/src/libtmux/experimental/mcp/vocabulary/__init__.py +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -69,6 +69,7 @@ aresize_pane, aresolve_relative_pane, arespawn_pane, + arun_in_pane, asearch_panes, aselect_pane, asend_input, @@ -88,6 +89,7 @@ resize_pane, resolve_relative_pane, respawn_pane, + run_in_pane, search_panes, select_pane, send_input, @@ -174,6 +176,7 @@ "aresize_pane", "aresolve_relative_pane", "arespawn_pane", + "arun_in_pane", "arun_tmux", "asearch_panes", "aselect_layout", @@ -214,6 +217,7 @@ "resize_pane", "resolve_relative_pane", "respawn_pane", + "run_in_pane", "run_tmux", "search_panes", "select_layout", diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 2dccb06c9..659d77dc8 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -1,11 +1,12 @@ """Pane-scope vocabulary: split, send, capture, resize, swap/join/break, select. -Beyond thin op wrappers, this module hosts the composed, caller-aware +Beyond single-operation tools, this module hosts the composed, caller-aware conveniences an agent reaches for that raw tmux makes awkward: -``capture_active_pane`` (no target), ``grep_pane`` (capture + filter, since tmux -has no server-side grep), ``search_panes`` ("which pane shows X?"), and the -geometry-resolved ``resolve_relative_pane`` / ``capture_relative_pane`` / -``grep_relative_pane`` / ``find_pane_by_position`` / directional ``select_pane``. +``run_in_pane`` (send + wait), ``capture_active_pane`` (no target), +``grep_pane`` (capture + filter, since tmux has no server-side grep), +``search_panes`` ("which pane shows X?"), and the geometry-resolved +``resolve_relative_pane`` / ``capture_relative_pane`` / ``grep_relative_pane`` / +``find_pane_by_position`` / directional ``select_pane``. The relative tools resolve layout geometry to a concrete ``%N`` (robust across tmux versions) and default their origin to the *caller's* pane; every single-target tool that could act on the wrong pane rejects a relative special @@ -75,6 +76,11 @@ ) from libtmux.experimental.ops._types import PaneId, Target +try: + from libtmux.experimental.mcp.events import MonitorResult +except ImportError: # pragma: no cover - only when the optional MCP extra is absent + MonitorResult = t.Any # type: ignore[misc, assignment] + #: Default ceiling on the panes ``search_panes`` captures, to bound fan-out cost. _SEARCH_PANE_CAP = 200 @@ -185,6 +191,65 @@ async def asend_input( ).raise_for_status() +async def arun_in_pane( + engine: AsyncTmuxEngine, + target: str | Target, + command: str, + *, + settle_ms: int = 750, + timeout: float = 30.0, + max_bytes: int = 131072, + needle: str | None = None, + suppress_history: bool = False, + snapshot: bool = True, + version: str | None = None, +) -> MonitorResult: + """Send a shell command to one pane, press Enter, and wait for output. + + This is the one-call path for command execution through a streaming control + engine. It sends the command with the same ``SendKeys`` operation as + ``send_input`` and then uses the shared live-output monitor to return the + folded output plus done metadata. The per-pane drive lock stays held across + both phases so two callers do not interleave commands in the same pane. + """ + from libtmux.experimental.agents.drive import pane_lock + from libtmux.experimental.mcp.events import ( + _StreamEngine, + _supports_stream, + await_pane_output, + ) + from libtmux.experimental.ops._types import render_target + + resolved = resolve_target(target) + reject_relative_special(resolved) + if not _supports_stream(engine): + msg = "run_in_pane requires a streaming control-mode engine" + raise RuntimeError(msg) + pane_key = render_target(resolved) or str(resolved) + async with pane_lock(pane_key): + ( + await arun( + SendKeys( + target=resolved, + keys=command, + enter=True, + suppress_history=suppress_history, + ), + engine, + version=version, + ) + ).raise_for_status() + return await await_pane_output( + t.cast("_StreamEngine", engine), + pane_key, + settle_ms=settle_ms, + timeout=timeout, + max_bytes=max_bytes, + needle=needle, + snapshot=snapshot, + ) + + async def acapture_pane( engine: AsyncTmuxEngine, target: str | Target, @@ -644,6 +709,7 @@ async def _raise_no_neighbour( split_pane = synced(asplit_pane) new_pane = synced(anew_pane) send_input = synced(asend_input) +run_in_pane = synced(arun_in_pane) capture_pane = synced(acapture_pane) capture_active_pane = synced(acapture_active_pane) grep_pane = synced(agrep_pane) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 089b8737e..5fe7bdaae 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -375,6 +375,65 @@ async def main() -> t.Any: assert data.done.pane_dead is None +def test_run_in_pane_sends_then_waits_for_output() -> None: + """run_in_pane sends one command, then returns the shared monitor result.""" + from libtmux.experimental.mcp.vocabulary.pane import arun_in_pane + + engine = InstrumentedEngine( + (b"%output %1 READY",), + done_line="%1\t0\t\t\tzsh\t2\t10\t0", + ) + + result = asyncio.run( + arun_in_pane( + engine, + "%1", + "echo READY", + needle="READY", + snapshot=False, + ), + ) + + assert result.pane_id == "%1" + assert result.reason == "matched" + assert result.captured_text == "READY" + assert result.snapshot_lines is None + assert engine.calls[0] == ("send-keys", "-t", "%1", "echo READY", "Enter") + assert ("attach-session", "-t", "$1") in engine.calls + + +def test_run_in_pane_registered_when_streaming() -> None: + """Streaming MCP servers expose and execute the one-call command runner.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + engine = InstrumentedEngine((b"%output %1 READY",)) + server = build_async_server( + engine, + events="push", + include_operations=False, + include_plan_tools=False, + ) + assert "run_in_pane" in _tool_names(server) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "run_in_pane", + { + "target": "%1", + "command": "echo READY", + "needle": "READY", + "snapshot": False, + }, + ) + return result.data + + data = asyncio.run(main()) + assert data.pane_id == "%1" + assert data.reason == "matched" + assert ("send-keys", "-t", "%1", "echo READY", "Enter") in engine.calls + + def test_monitor_snapshot_false_omits_grid() -> None: """snapshot=False leaves snapshot_lines None and skips the capture.""" from libtmux.experimental.mcp.fastmcp_adapter import build_async_server diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py index 40592baa3..aab6ce48d 100644 --- a/tests/experimental/mcp/test_prompts.py +++ b/tests/experimental/mcp/test_prompts.py @@ -95,7 +95,7 @@ def test_prompt_bodies_use_engine_ops_vocabulary() -> None: for foreign in _FOREIGN_TOOLS: assert foreign not in body, f"foreign tool {foreign!r} leaked" # the canonical engine-ops verbs appear - assert "send_input" in run_and_wait("ls", "%1") + assert "run_in_pane" in run_and_wait("ls", "%1") assert "split_pane" in build_dev_workspace("dev") assert "wait_for_output" in interrupt_gracefully("%1") @@ -111,11 +111,9 @@ def _wait_for_output_bodies() -> tuple[WaitForOutputCase, ...]: from libtmux.experimental.mcp.prompts import ( diagnose_failing_pane, interrupt_gracefully, - run_and_wait, ) return ( - WaitForOutputCase("run_and_wait", run_and_wait("ls", "%1")), WaitForOutputCase("diagnose_failing_pane", diagnose_failing_pane("%1")), WaitForOutputCase("interrupt_gracefully", interrupt_gracefully("%1")), ) From abd4f92f602846350bc0e965d269e8009441287b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:42:22 -0500 Subject: [PATCH 210/223] Agents(feat[state]): Add done why: Agent orchestration needs a first-class state for completed turns that need review, distinct from idle shells and approval waits. what: - Add AgentState.DONE to parsing, rollups, status-line rendering, and waits - Emit done from Claude/Codex turn-complete hooks and keep done send-ready - Cover store, MCP, hook, wait, drive, and query handling for done --- docs/experimental.md | 83 +++++++++++++++++++ src/libtmux/experimental/agents/drive.py | 6 +- src/libtmux/experimental/agents/hooks/base.py | 4 +- .../experimental/agents/hooks/claude.py | 4 +- .../experimental/agents/hooks/codex.py | 15 ++-- src/libtmux/experimental/agents/state.py | 3 + src/libtmux/experimental/agents/statusline.py | 2 + .../experimental/mcp/vocabulary/agents.py | 5 +- .../experimental/agents/hooks/test_claude.py | 2 +- tests/experimental/agents/hooks/test_codex.py | 1 + .../agents/hooks/test_registry.py | 1 + tests/experimental/agents/test_drive.py | 18 ++++ tests/experimental/agents/test_state.py | 1 + tests/experimental/agents/test_statusline.py | 3 +- tests/experimental/agents/test_store.py | 1 + tests/experimental/agents/test_wait.py | 13 +++ tests/experimental/mcp/test_agents_tools.py | 9 +- tests/experimental/test_query_agents.py | 11 +++ 18 files changed, 161 insertions(+), 21 deletions(-) diff --git a/docs/experimental.md b/docs/experimental.md index d3b112412..e28158955 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -173,3 +173,86 @@ the code. ```{tmuxop-catalog} :safety: destructive ``` + +## Agents + +```{warning} +The agent-state monitor is experimental and subject to change without notice. +``` + +The `libtmux.experimental.agents` package gives you a live, server-side view +of every coding agent running across your tmux sessions. A +{class}`~libtmux.experimental.agents.monitor.AgentMonitor` subscribes to a +control-mode engine, classifies incoming tmux notifications, and coalesces them +into a per-pane {class}`~libtmux.experimental.agents.state.Agent` record — +carrying the agent's name, its current +{class}`~libtmux.experimental.agents.state.AgentState` (`RUNNING`, +`AWAITING_INPUT`, `DONE`, `IDLE`, `EXITED`, or `UNKNOWN`), the timestamp of the +last transition, and a liveness flag refreshed from the pane tree on each +reconcile. `DONE` means a turn completed and needs review, distinct from an +idle shell. A *local* pane whose process has exited is marked `EXITED`. Remote +(SSH) panes have no local pid to probe, so they are left at their last-known +state and only become `EXITED` when their tmux pane disappears (no keepalive/TTL +in v1). + +Agents report their state via tmux option subscriptions or OSC escape sequences. +When both signals arrive for the same pane the monitor applies a +last-writer-wins merge so the store stays consistent without locks. On every +engine (re)connect the monitor runs a full-pane reconciliation — it lists all +panes, compares them against the stored snapshot, emits the minimal diff for +panes that vanished, and refreshes liveness — then re-subscribes to the +notification stream. Because this runs on each reconnect (not on a fixed +timer), the monitor self-heals across a tmux restart or socket blip: a dropped +connection never leaves the store serving a stale snapshot. + +```python +import asyncio + +from libtmux import Server +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +async def main() -> None: + engine = AsyncControlModeEngine.for_server(Server()) + monitor = AgentMonitor(engine) + await monitor.start() + try: + for agent in monitor.agents: + print(agent.pane_id, agent.state, "awaiting" if agent.is_awaiting else "") + finally: + await monitor.stop() + + +asyncio.run(main()) +``` + +### Installing agent hooks + +Before a coding agent can report state, its lifecycle hooks must be installed. +The {class}`~libtmux.experimental.agents.hooks.base.AgentHook` subclasses do not +touch tmux: `ClaudeCodeHook` merges hook entries into `~/.claude/settings.json` +and `CodexHook` into `~/.codex/config.toml`, leaving the rest of each file +untouched. Every installed hook runs the `libtmux-agent-emit` console script on +the agent's lifecycle events, and that script is what writes the agent's state +to tmux — a per-pane `@agent_state` option locally, or an OSC 3008 escape +sequence over SSH — exactly the signals the monitor subscribes to. The MCP tool +`install_agent_hooks` runs the matching installer on demand — pass `"claude"` or +`"codex"` as the agent name. + +### MCP tools + +When `libtmux-mcp` is running with the agent monitor wired in, these tools are +exposed to LLM clients: + +- **`list_agents`** — returns a snapshot of every currently tracked agent: + pane id, name, state string, seconds since last transition, and liveness. +- **`watch_agents`** — collects state-change events for a bounded window (default + 5 s) and returns them as a list, useful for agents that need to wait for a + peer to reach `AWAITING_INPUT` before sending a message. +- **`wait_for_agent`** — blocks on the monitor's in-process store until a pane + reaches a target state such as `AWAITING_INPUT` or `DONE`. +- **`send_to_agent`** — waits for `AWAITING_INPUT`, `DONE`, or `IDLE`, then sends + a prompt through one folded tmux dispatch. +- **`install_agent_hooks`** — installs the named agent's shell hooks into the + session so the monitor can begin receiving state signals. diff --git a/src/libtmux/experimental/agents/drive.py b/src/libtmux/experimental/agents/drive.py index af39623cd..754331519 100644 --- a/src/libtmux/experimental/agents/drive.py +++ b/src/libtmux/experimental/agents/drive.py @@ -44,7 +44,11 @@ from libtmux.experimental.agents.monitor import AgentMonitor #: Default states an agent is considered "ready to receive a prompt" in. -READY_STATES: tuple[AgentState, ...] = (AgentState.AWAITING_INPUT, AgentState.IDLE) +READY_STATES: tuple[AgentState, ...] = ( + AgentState.AWAITING_INPUT, + AgentState.DONE, + AgentState.IDLE, +) # Process-wide per-pane logical drive locks (the comprehensive chokepoint). Held # weakly: a lock survives exactly as long as a sender references it (inside an diff --git a/src/libtmux/experimental/agents/hooks/base.py b/src/libtmux/experimental/agents/hooks/base.py index 92c8b6f8b..c0b6a07ec 100644 --- a/src/libtmux/experimental/agents/hooks/base.py +++ b/src/libtmux/experimental/agents/hooks/base.py @@ -23,13 +23,13 @@ #: >>> EVENT_STATE["needs_approval"] #: 'awaiting_input' #: >>> EVENT_STATE["turn_end"] -#: 'awaiting_input' +#: 'done' #: >>> EVENT_STATE["session_start"] #: 'idle' EVENT_STATE: dict[str, str] = { "turn_start": "running", "needs_approval": "awaiting_input", - "turn_end": "awaiting_input", + "turn_end": "done", "session_start": "idle", } diff --git a/src/libtmux/experimental/agents/hooks/claude.py b/src/libtmux/experimental/agents/hooks/claude.py index 051a1bbf1..3681077cc 100644 --- a/src/libtmux/experimental/agents/hooks/claude.py +++ b/src/libtmux/experimental/agents/hooks/claude.py @@ -33,11 +33,11 @@ #: >>> _CLAUDE_EVENT_STATE["UserPromptSubmit"] #: 'running' #: >>> _CLAUDE_EVENT_STATE["Stop"] -#: 'awaiting_input' +#: 'done' _CLAUDE_EVENT_STATE: dict[str, str] = { "UserPromptSubmit": "running", "Notification": "awaiting_input", - "Stop": "awaiting_input", + "Stop": "done", "SessionStart": "idle", } diff --git a/src/libtmux/experimental/agents/hooks/codex.py b/src/libtmux/experimental/agents/hooks/codex.py index b2c0e4ab6..ab7a7f3de 100644 --- a/src/libtmux/experimental/agents/hooks/codex.py +++ b/src/libtmux/experimental/agents/hooks/codex.py @@ -8,15 +8,14 @@ The modern ``[hooks]`` TOML format (array-of-tables ``[[hooks.]]``) is the primary mechanism. Codex's older single-program ``notify`` hook (fires -on turn-complete only, emitting ``awaiting_input``) is a fallback for old -Codex versions — it is **not** implemented in v1; modern ``[hooks]`` is -primary. +on turn-complete only, emitting ``done``) is a fallback for old Codex +versions — it is **not** implemented in v1; modern ``[hooks]`` is primary. Codex event → state mapping:: user_prompt_submit → running permission_request → awaiting_input - stop → awaiting_input + stop → done session_start → idle Each hook fires on a named Codex lifecycle event. Codex passes event JSON @@ -60,7 +59,7 @@ _CODEX_EVENT_STATE: dict[str, str] = { "user_prompt_submit": "running", "permission_request": "awaiting_input", - "stop": "awaiting_input", + "stop": "done", "session_start": "idle", } @@ -106,9 +105,9 @@ class CodexHook: ----- **Legacy notify fallback (not implemented in v1).** Old Codex versions support a single-program ``notify`` hook that fires - on turn-complete only (equivalent to ``awaiting_input``). Modern - ``[hooks]`` is the primary mechanism; the ``notify`` path is documented - here for future reference but is not implemented. + on turn-complete only (equivalent to ``done``). Modern ``[hooks]`` is the + primary mechanism; the ``notify`` path is documented here for future + reference but is not implemented. Examples -------- diff --git a/src/libtmux/experimental/agents/state.py b/src/libtmux/experimental/agents/state.py index 5c4804d00..bf2ebe098 100644 --- a/src/libtmux/experimental/agents/state.py +++ b/src/libtmux/experimental/agents/state.py @@ -19,6 +19,7 @@ class AgentState(str, enum.Enum): RUNNING = "running" AWAITING_INPUT = "awaiting_input" + DONE = "done" IDLE = "idle" EXITED = "exited" UNKNOWN = "unknown" @@ -34,6 +35,8 @@ def from_signal(cls, value: str) -> AgentState: -------- >>> AgentState.from_signal("AWAITING_INPUT") + >>> AgentState.from_signal("done") + >>> AgentState.from_signal("garbage") """ diff --git a/src/libtmux/experimental/agents/statusline.py b/src/libtmux/experimental/agents/statusline.py index 5d092bf46..acd42fd95 100644 --- a/src/libtmux/experimental/agents/statusline.py +++ b/src/libtmux/experimental/agents/statusline.py @@ -31,6 +31,7 @@ #: Short per-state labels for the default tally (override via ``labels``). DEFAULT_LABELS: dict[AgentState, str] = { AgentState.AWAITING_INPUT: "wait", + AgentState.DONE: "done", AgentState.IDLE: "idle", AgentState.RUNNING: "run", AgentState.EXITED: "exit", @@ -40,6 +41,7 @@ #: The order states appear in the default tally (most-urgent first). _ATTENTION_ORDER: tuple[AgentState, ...] = ( AgentState.AWAITING_INPUT, + AgentState.DONE, AgentState.IDLE, AgentState.RUNNING, AgentState.UNKNOWN, diff --git a/src/libtmux/experimental/mcp/vocabulary/agents.py b/src/libtmux/experimental/mcp/vocabulary/agents.py index 83d153139..d44db0a21 100644 --- a/src/libtmux/experimental/mcp/vocabulary/agents.py +++ b/src/libtmux/experimental/mcp/vocabulary/agents.py @@ -285,7 +285,7 @@ async def wait_for_agent( pane_id : str The pane to watch (e.g. ``"%1"``). target : str - One state or a comma-separated set (e.g. ``"awaiting_input,idle"``). + One state or a comma-separated set (e.g. ``"awaiting_input,done"``). timeout_s : float Seconds to wait before giving up (default 30). @@ -347,7 +347,8 @@ async def send_to_agent( text : str The prompt to deliver (multi-line is pasted, then submitted). wait_ready : bool - Wait for ``awaiting_input``/``idle`` before sending (default True). + Wait for ``awaiting_input``/``done``/``idle`` before sending + (default True). timeout_s : float Readiness-wait budget in seconds (default 30). key : str or None diff --git a/tests/experimental/agents/hooks/test_claude.py b/tests/experimental/agents/hooks/test_claude.py index 25baf7cc2..d455cb2f2 100644 --- a/tests/experimental/agents/hooks/test_claude.py +++ b/tests/experimental/agents/hooks/test_claude.py @@ -30,7 +30,7 @@ def test_install_status_uninstall_roundtrip(tmp_path: pathlib.Path) -> None: data = json.loads(settings.read_text()) stop_cmds = [h["command"] for grp in data["hooks"]["Stop"] for h in grp["hooks"]] - assert any("libtmux-agent-emit awaiting_input" in c for c in stop_cmds) + assert any("libtmux-agent-emit done" in c for c in stop_cmds) assert "echo user-owned" in stop_cmds # never clobber the user's hook hook.uninstall() diff --git a/tests/experimental/agents/hooks/test_codex.py b/tests/experimental/agents/hooks/test_codex.py index ff8861bef..501c2e2de 100644 --- a/tests/experimental/agents/hooks/test_codex.py +++ b/tests/experimental/agents/hooks/test_codex.py @@ -30,6 +30,7 @@ def test_install_writes_event_hooks(tmp_path: pathlib.Path) -> None: assert "permission_request" in text assert "libtmux-agent-emit awaiting_input" in text assert "stop" in text + assert "libtmux-agent-emit done" in text assert "session_start" in text assert "libtmux-agent-emit idle" in text assert 'model = "o4"' in text # untouched diff --git a/tests/experimental/agents/hooks/test_registry.py b/tests/experimental/agents/hooks/test_registry.py index 52e9de379..64b6decb5 100644 --- a/tests/experimental/agents/hooks/test_registry.py +++ b/tests/experimental/agents/hooks/test_registry.py @@ -12,6 +12,7 @@ def test_event_state_map_is_canonical() -> None: """EVENT_STATE maps the four canonical lifecycle events to state strings.""" assert EVENT_STATE["turn_start"] == "running" assert EVENT_STATE["needs_approval"] == "awaiting_input" + assert EVENT_STATE["turn_end"] == "done" def test_registry_has_claude_and_codex() -> None: diff --git a/tests/experimental/agents/test_drive.py b/tests/experimental/agents/test_drive.py index 491234c4f..d26da8b3b 100644 --- a/tests/experimental/agents/test_drive.py +++ b/tests/experimental/agents/test_drive.py @@ -5,6 +5,7 @@ import asyncio from libtmux.experimental.agents.drive import ( + READY_STATES, DedupLedger, SendOutcome, pane_lock, @@ -12,6 +13,7 @@ send_to_agents, ) from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import AgentState from libtmux.experimental.agents.wait import WaitReason from libtmux.experimental.engines.base import CommandRequest, CommandResult @@ -88,6 +90,22 @@ async def main() -> tuple[bool, int, CommandRequest]: assert ";" in args # the ops are chained into one invocation +def test_done_is_ready_for_the_next_prompt() -> None: + """A completed turn is ready by default so follow-up sends do not time out.""" + + async def main() -> tuple[bool, int]: + engine = _RecordingEngine() + monitor = AgentMonitor(engine) + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : done") + outcome = await send_to_agent(monitor, "%1", "review") + return outcome.sent, len(engine.requests) + + assert AgentState.DONE in READY_STATES + sent, dispatch_count = asyncio.run(main()) + assert sent is True + assert dispatch_count == 1 + + def test_send_to_agent_skips_dispatch_when_not_ready() -> None: """A pane stuck RUNNING is not driven; no keystrokes are dispatched.""" diff --git a/tests/experimental/agents/test_state.py b/tests/experimental/agents/test_state.py index 367de9b25..77cbeb81b 100644 --- a/tests/experimental/agents/test_state.py +++ b/tests/experimental/agents/test_state.py @@ -9,6 +9,7 @@ def test_from_signal_maps_known_and_unknown() -> None: """Test AgentState.from_signal maps known and unknown states.""" assert AgentState.from_signal("running") is AgentState.RUNNING assert AgentState.from_signal("awaiting_input") is AgentState.AWAITING_INPUT + assert AgentState.from_signal("done") is AgentState.DONE assert AgentState.from_signal("idle") is AgentState.IDLE assert AgentState.from_signal("garbage") is AgentState.UNKNOWN diff --git a/tests/experimental/agents/test_statusline.py b/tests/experimental/agents/test_statusline.py index b5e8e3777..a17a733ec 100644 --- a/tests/experimental/agents/test_statusline.py +++ b/tests/experimental/agents/test_statusline.py @@ -66,8 +66,9 @@ def test_render_tally_in_attention_order() -> None: _agent("%2", AgentState.RUNNING), _agent("%3", AgentState.RUNNING), _agent("%4", AgentState.IDLE), + _agent("%5", AgentState.DONE), ] - assert render_status_line(rows) == "wait:1 idle:1 run:2" + assert render_status_line(rows) == "wait:1 done:1 idle:1 run:2" def test_render_empty_fleet() -> None: diff --git a/tests/experimental/agents/test_store.py b/tests/experimental/agents/test_store.py index 72fde5606..b2c601b9c 100644 --- a/tests/experimental/agents/test_store.py +++ b/tests/experimental/agents/test_store.py @@ -84,6 +84,7 @@ class StateCase(t.NamedTuple): STATE_CASES = ( StateCase("known_round_trips", "running", AgentState.RUNNING), + StateCase("done_round_trips", "done", AgentState.DONE), StateCase("unknown_future_state", "paused", AgentState.UNKNOWN), StateCase("garbage", "???", AgentState.UNKNOWN), ) diff --git a/tests/experimental/agents/test_wait.py b/tests/experimental/agents/test_wait.py index 88455d013..d152bdee1 100644 --- a/tests/experimental/agents/test_wait.py +++ b/tests/experimental/agents/test_wait.py @@ -78,6 +78,19 @@ async def main() -> WaitReason: assert asyncio.run(main()) is WaitReason.REACHED +def test_wait_can_target_done_state() -> None: + """DONE is a first-class target for turn-complete fan-in waits.""" + + async def main() -> WaitReason: + mon = AgentMonitor(_FakeEngine()) + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : done") + return ( + await wait_for_agent_state(mon, "%1", AgentState.DONE, timeout=1.0) + ).reason + + assert asyncio.run(main()) is WaitReason.REACHED + + def test_wait_wakes_on_later_ingest() -> None: """A parked wait resolves the moment the drain ingests the target state.""" diff --git a/tests/experimental/mcp/test_agents_tools.py b/tests/experimental/mcp/test_agents_tools.py index 1f9470c7d..148e7b15f 100644 --- a/tests/experimental/mcp/test_agents_tools.py +++ b/tests/experimental/mcp/test_agents_tools.py @@ -49,9 +49,9 @@ def add_tool(self, tool: t.Any) -> None: def test_list_agents_reflects_ingested_state() -> None: """list_agents shape: ingested option-line produces the expected pane dict.""" mon = AgentMonitor(_FakeEngine()) - mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : done") listing = [{"pane_id": a.pane_id, "state": a.state.value} for a in mon.agents] - assert {"pane_id": "%1", "state": "running"} in listing + assert {"pane_id": "%1", "state": "done"} in listing def test_watch_agents_observes_store_without_subscribing() -> None: @@ -147,13 +147,14 @@ def test_wait_for_agent_tool_reports_reached() -> None: mcp = _CapturingMcp() monitor = register_agents(t.cast("FastMCP[t.Any]", mcp), _FakeEngine()) - monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") + monitor.ingest("%subscription-changed agentstate $0 @0 1 %1 : done") wait = mcp.tools["wait_for_agent"].fn - result = asyncio.run(wait("%1", "idle", 0.5)) + result = asyncio.run(wait("%1", "done", 0.5)) assert result["reached"] is True assert result["reason"] == "reached" + assert result["state"] == "done" def test_send_to_agent_tool_dispatches_when_ready() -> None: diff --git a/tests/experimental/test_query_agents.py b/tests/experimental/test_query_agents.py index 50fe3cb39..9465cfcb7 100644 --- a/tests/experimental/test_query_agents.py +++ b/tests/experimental/test_query_agents.py @@ -83,6 +83,17 @@ def test_most_urgent_picks_blocked_agent() -> None: assert top.pane_id == "%2" +def test_done_outranks_idle_by_default() -> None: + """DONE is visible above idle in the default attention ladder.""" + rows = [ + _agent("%1", AgentState.IDLE), + _agent("%2", AgentState.DONE), + ] + top = agents().most_urgent(rows) + assert top is not None + assert top.pane_id == "%2" + + def test_most_urgent_none_when_empty() -> None: """``most_urgent`` returns ``None`` when nothing matches.""" assert agents().filter(name="nobody").most_urgent([]) is None From 1e3babff4c49897f5be354b3b414ab7095bd2845 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:45:43 -0500 Subject: [PATCH 211/223] Workspace(feat[set]): Build workspace sets why: Agents can now submit and build a family of declarative workspaces in one backend call while keeping the chainable engine and planner in charge of dispatch cost. what: - Add WorkspaceSet compile/build APIs with SlotRef and host-step rebasing - Add workspace_status projections over snapshots and agent state - Expose build_workspaces through MCP and document the new workspace-set flow --- src/libtmux/experimental/mcp/__init__.py | 9 +- .../experimental/mcp/fastmcp_adapter.py | 44 ++++++++- src/libtmux/experimental/mcp/plan_tools.py | 67 +++++++++++++ src/libtmux/experimental/workspace/status.py | 99 +++++++++++++++++++ .../contract/test_workspace_sets.py | 55 +++++++++++ tests/experimental/mcp/test_mcp_projection.py | 18 ++++ tests/experimental/mcp/test_safety_gate.py | 2 + 7 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 src/libtmux/experimental/workspace/status.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 712974663..940ecfd3c 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -6,7 +6,8 @@ :class:`~.registry.OperationToolRegistry`), resolves agent string/dict targets (:func:`~.target_resolver.resolve_target`), and exposes plan tools (:func:`~.plan_tools.preview_plan`, :func:`~.plan_tools.execute_plan`, -:func:`~.plan_tools.result_schema`) plus :func:`~.plan_tools.build_workspace`. +:func:`~.plan_tools.result_schema`) plus +:func:`~.plan_tools.build_workspace` / :func:`~.plan_tools.build_workspaces`. It has **no** MCP-framework dependency (no fastmcp/pydantic at import time); a thin adapter in a server (e.g. libtmux-mcp) binds these descriptors at runtime. @@ -31,9 +32,12 @@ PlanOutcome, PlanPreview, ResultSchema, + WorkspaceSetOutcome, abuild_workspace, + abuild_workspaces, aexecute_plan, build_workspace, + build_workspaces, execute_plan, explain_plan, preview_plan, @@ -279,9 +283,12 @@ def main(argv: Sequence[str] | None = None) -> None: "SessionResult", "ToolDescriptor", "WindowResult", + "WorkspaceSetOutcome", "abuild_workspace", + "abuild_workspaces", "aexecute_plan", "build_workspace", + "build_workspaces", "capture_pane", "create_session", "create_window", diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 23fd33cfa..f28a827b6 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -143,7 +143,7 @@ def _instructions(ctx: CallerContext, *, events_enabled: bool = False) -> str: closer = ( "The curated tools cover most needs; the per-operation surface (op_*) " "and the plan tools (preview_plan/execute_plan/result_schema/" - "build_workspace) are power-use." + "build_workspace/build_workspaces) are power-use." ) if events_enabled: closer += ( @@ -512,8 +512,29 @@ async def build_workspace( ) return outcome.to_dict() + async def build_workspaces( + specs: list[dict[str, t.Any]], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build multiple declarative workspaces in one merged plan.""" + outcome = await _plan.abuild_workspaces( + specs, + t.cast("AsyncTmuxEngine", engine), + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + "sessions": outcome.sessions, + "reused": outcome.reused, + } + tools.append((execute_plan, "mutating")) tools.append((build_workspace, "mutating")) + tools.append((build_workspaces, "mutating")) else: def execute_plan( # type: ignore[misc] @@ -544,8 +565,29 @@ def build_workspace( # type: ignore[misc] ) return outcome.to_dict() + def build_workspaces( # type: ignore[misc] + specs: list[dict[str, t.Any]], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build multiple declarative workspaces in one merged plan.""" + outcome = _plan.build_workspaces( + specs, + t.cast("TmuxEngine", engine), + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + "sessions": outcome.sessions, + "reused": outcome.reused, + } + tools.append((execute_plan, "mutating")) tools.append((build_workspace, "mutating")) + tools.append((build_workspaces, "mutating")) for fn, safety in tools: annotations = ToolAnnotations( diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py index 199b97e93..3e1a0064d 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -94,6 +94,17 @@ def _to_outcome(result: PlanResult) -> PlanOutcome: ) +@dataclass(frozen=True) +class WorkspaceSetOutcome: + """The result of executing a workspace-set build.""" + + ok: bool + results: list[dict[str, t.Any]] + bindings: dict[str, str] + sessions: list[str] + reused: list[str] + + def execute_plan( plan: LazyPlan, engine: TmuxEngine, @@ -188,3 +199,59 @@ async def abuild_workspace( return _to_outcome( await analyze(spec).abuild(engine, version=version, preflight=preflight), ) + + +def build_workspaces( + specs: t.Sequence[t.Mapping[str, t.Any] | str], + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> WorkspaceSetOutcome: + """Build multiple declarative workspaces as one merged plan.""" + from libtmux.experimental.workspace import ( + analyze, + build_workspaces as run_workspaces, + ) + + result = run_workspaces( + [analyze(spec) for spec in specs], + engine, + version=version, + preflight=preflight, + ) + return WorkspaceSetOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.result.results], + bindings=bindings_to_dict(result.bindings), + sessions=list(result.sessions), + reused=list(result.reused), + ) + + +async def abuild_workspaces( + specs: t.Sequence[t.Mapping[str, t.Any] | str], + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> WorkspaceSetOutcome: + """Async sibling of :func:`build_workspaces`.""" + from libtmux.experimental.workspace import ( + abuild_workspaces as arun_workspaces, + analyze, + ) + + result = await arun_workspaces( + [analyze(spec) for spec in specs], + engine, + version=version, + preflight=preflight, + ) + return WorkspaceSetOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.result.results], + bindings=bindings_to_dict(result.bindings), + sessions=list(result.sessions), + reused=list(result.reused), + ) diff --git a/src/libtmux/experimental/workspace/status.py b/src/libtmux/experimental/workspace/status.py new file mode 100644 index 000000000..af3d4f5dc --- /dev/null +++ b/src/libtmux/experimental/workspace/status.py @@ -0,0 +1,99 @@ +"""Read-side status projections for declared workspaces.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.query import ATTENTION + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from libtmux.experimental.agents.monitor import AgentMonitor + from libtmux.experimental.agents.state import Agent, AgentState + from libtmux.experimental.models import ServerSnapshot + from libtmux.experimental.models.snapshots import SessionSnapshot + from libtmux.experimental.workspace.ir import Workspace + +AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] + + +@dataclass(frozen=True) +class WorkspaceStatus: + """Status for one declared workspace against a live server snapshot.""" + + name: str + exists: bool + session_id: str | None = None + windows: int = 0 + panes: int = 0 + agents: tuple[Agent, ...] = () + agent_state: AgentState | None = None + + +def _agent_rows(source: AgentSource) -> tuple[Agent, ...]: + """Resolve an agent source without making a tmux call.""" + store_agents = getattr(source, "agents", None) + if store_agents is not None: + return tuple(store_agents) + return tuple(t.cast("Sequence[Agent]", source)) + + +def _session_pane_ids(session: SessionSnapshot) -> set[str]: + """Return the pane ids contained in *session*.""" + return {pane.pane_id for window in session.windows for pane in window.panes} + + +def _agent_state(rows: Iterable[Agent]) -> AgentState | None: + """Return the most urgent state for *rows*, or ``None`` when empty.""" + agents = tuple(rows) + if not agents: + return None + return max(agents, key=lambda agent: ATTENTION.get(agent.state, -1)).state + + +def workspace_status( + workspaces: Iterable[Workspace], + snapshot: ServerSnapshot, + agents_source: AgentSource = (), +) -> tuple[WorkspaceStatus, ...]: + """Project declared workspaces against a server snapshot and agent records. + + The projection is pure: callers can feed one + :class:`~libtmux.experimental.models.ServerSnapshot` and an in-process + agent store, then refresh UI state repeatedly with zero tmux calls. + + Examples + -------- + >>> from libtmux.experimental.models import ServerSnapshot + >>> from libtmux.experimental.workspace import Window, Workspace + >>> snap = ServerSnapshot.from_pane_rows([ + ... {"session_id": "$1", "session_name": "dev", "window_id": "@1", + ... "pane_id": "%1"}, + ... ]) + >>> workspace_status([Workspace("dev", windows=[Window("w")])], snap)[0].exists + True + """ + by_name = {session.name: session for session in snapshot.sessions} + agents = _agent_rows(agents_source) + statuses: list[WorkspaceStatus] = [] + for workspace in workspaces: + session = by_name.get(workspace.name) + if session is None: + statuses.append(WorkspaceStatus(name=workspace.name, exists=False)) + continue + pane_ids = _session_pane_ids(session) + session_agents = tuple(agent for agent in agents if agent.pane_id in pane_ids) + statuses.append( + WorkspaceStatus( + name=workspace.name, + exists=True, + session_id=session.session_id, + windows=len(session.windows), + panes=sum(len(window.panes) for window in session.windows), + agents=session_agents, + agent_state=_agent_state(session_agents), + ), + ) + return tuple(statuses) diff --git a/tests/experimental/contract/test_workspace_sets.py b/tests/experimental/contract/test_workspace_sets.py index 5afc72af0..f4a3bc8f7 100644 --- a/tests/experimental/contract/test_workspace_sets.py +++ b/tests/experimental/contract/test_workspace_sets.py @@ -6,8 +6,10 @@ import dataclasses import typing as t +from libtmux.experimental.agents.state import Agent, AgentState from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.models import ServerSnapshot from libtmux.experimental.ops import SequentialPlanner from libtmux.experimental.ops._types import SlotRef from libtmux.experimental.workspace import ( @@ -19,6 +21,7 @@ WorkspaceSet, build_workspaces, compile_workspaces, + workspace_status, ) if t.TYPE_CHECKING: @@ -165,3 +168,55 @@ def test_workspace_set_async_build_matches_sync_shape() -> None: assert len(outcome.result.results) == len( compile_workspaces(workspace_set.workspaces).plan.operations, ) + + +def test_workspace_status_projects_snapshots_and_agents_without_tmux() -> None: + """workspace_status joins workspace specs, server snapshots, and agent records.""" + snapshot = ServerSnapshot.from_pane_rows( + [ + { + "session_id": "$1", + "session_name": "dev-api", + "window_id": "@1", + "window_name": "editor", + "pane_id": "%1", + }, + { + "session_id": "$2", + "session_name": "dev-docs", + "window_id": "@2", + "window_name": "editor", + "pane_id": "%2", + }, + ], + ) + agents = [ + Agent( + pane_id="%1", + key="%1", + name="claude", + state=AgentState.AWAITING_INPUT, + since=0.0, + source="option", + pid=None, + alive=True, + ), + ] + + statuses = workspace_status( + [ + Workspace(name="dev-api", windows=[Window("editor")]), + Workspace(name="dev-docs", windows=[Window("editor")]), + Workspace(name="missing", windows=[Window("editor")]), + ], + snapshot, + agents, + ) + + assert [(item.name, item.exists, item.session_id) for item in statuses] == [ + ("dev-api", True, "$1"), + ("dev-docs", True, "$2"), + ("missing", False, None), + ] + assert statuses[0].agent_state == AgentState.AWAITING_INPUT + assert statuses[1].agent_state is None diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 5af9a1440..7376586d5 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -12,6 +12,7 @@ from libtmux.experimental.mcp import ( OperationToolRegistry, build_workspace, + build_workspaces, execute_plan, explain_plan, preview_plan, @@ -156,3 +157,20 @@ def test_build_workspace_tool_offline() -> None: ) assert outcome.ok assert outcome.bindings["0"].startswith("$") + + +def test_build_workspaces_tool_offline() -> None: + """build_workspaces runs multiple declarative specs as one tool call.""" + outcome = build_workspaces( + [ + {"session_name": "api", "windows": [{"window_name": "w", "panes": ["a"]}]}, + {"session_name": "docs", "windows": [{"window_name": "w", "panes": ["b"]}]}, + ], + ConcreteEngine(), + preflight=False, + ) + + assert outcome.ok + assert outcome.sessions == ["api", "docs"] + assert outcome.reused == [] + assert outcome.bindings["0"].startswith("$") diff --git a/tests/experimental/mcp/test_safety_gate.py b/tests/experimental/mcp/test_safety_gate.py index 6dac00d50..1d3b944d9 100644 --- a/tests/experimental/mcp/test_safety_gate.py +++ b/tests/experimental/mcp/test_safety_gate.py @@ -79,4 +79,6 @@ def test_safety_gate_plan_tool_tier() -> None: mutating = _names_at("mutating") assert "preview_plan" in readonly # readonly plan tool always visible assert "build_workspace" not in readonly # mutating plan tool hidden ... + assert "build_workspaces" not in readonly assert "build_workspace" in mutating # ... visible at mutating + assert "build_workspaces" in mutating From 911bcbb7ebfa3d68e142b54c80706777d034977f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:55:28 -0500 Subject: [PATCH 212/223] Mcp(fix[events]): Attach raw output streams why: Raw tmux %output notifications are silent until the control-mode client attaches to a session, so output-source event tools could appear healthy while returning empty streams. what: - Add target-aware attachment for watch_events and poll_events in output mode - Report a visible tmux://events error before an output-source pull stream is attached - Cover push, pull, resource, and live raw-output event paths --- src/libtmux/experimental/mcp/events.py | 61 ++++++++- tests/experimental/mcp/test_events.py | 144 ++++++++++++++++++++ tests/experimental/mcp/test_monitor_live.py | 55 ++++++++ 3 files changed, 255 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index 1c99b38e8..ccaebb439 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -17,7 +17,9 @@ The ``source`` axis selects the substrate: ``"output"`` streams raw notifications; ``"subscription"`` first installs ``refresh-client -B`` format -subscriptions, tmux's debounced, server-side change detection. +subscriptions, tmux's debounced, server-side change detection. Raw pane output +requires the control-mode client to be attached to a session; tools that use the +``"output"`` source accept a ``target`` so they can attach before subscribing. """ from __future__ import annotations @@ -50,6 +52,10 @@ EventSource = t.Literal["subscription", "output"] _RING_SIZE = 1024 +_OUTPUT_ATTACH_MESSAGE = ( + "event_source='output' requires target=... so the control-mode client can " + "attach before %output is streamed" +) # tmux format read once at settle to fill DoneMetadata (tab-joined, one round-trip). _DONE_FORMAT = "\t".join( @@ -220,6 +226,10 @@ def since(self, seq: int) -> dict[str, t.Any]: out["error"] = self._error return out + def unattached(self, message: str = _OUTPUT_ATTACH_MESSAGE) -> dict[str, t.Any]: + """Return an error payload without starting the background drainer.""" + return {"events": [], "cursor": self._seq, "error": message} + def register_events( mcp: FastMCP, @@ -240,7 +250,7 @@ def register_events( if mode in ("push", "both"): _register_push(mcp, stream, source=source) if mode in ("pull", "both"): - _register_pull(mcp, stream) + _register_pull(mcp, stream, source=source) _register_monitor(mcp, stream) @@ -260,6 +270,7 @@ async def watch_events( max_events: int = 20, timeout: float = 30.0, subscriptions: list[str] | None = None, + target: str | None = None, ) -> dict[str, t.Any]: """Stream live tmux notifications, pushing each as an MCP log message. @@ -267,9 +278,13 @@ async def watch_events( comes first. ``kinds`` filters by notification kind (e.g. ``window-add``, ``output``). With ``source="subscription"``, pass ``subscriptions`` as ``name:what:format`` specs to install ``refresh-client -B`` watches first. + With ``source="output"``, pass ``target`` so the control client can attach + to the target's session before subscribing to raw ``%output``. """ if source == "subscription": await _install_subscriptions(engine, subscriptions) + else: + await _ensure_output_attached(engine, target) collected: list[dict[str, t.Any]] = [] async def _collect() -> None: @@ -295,7 +310,12 @@ async def _collect() -> None: mcp.add_tool(tool) -def _register_pull(mcp: FastMCP, engine: _StreamEngine) -> None: +def _register_pull( + mcp: FastMCP, + engine: _StreamEngine, + *, + source: EventSource, +) -> None: """Register the ``tmux://events`` resource + ``poll_events`` pull tool.""" from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations @@ -304,6 +324,8 @@ def _register_pull(mcp: FastMCP, engine: _StreamEngine) -> None: async def read_events() -> dict[str, t.Any]: """Return all buffered tmux events (starts the reader on first read).""" + if source == "output" and getattr(engine, "_attached_session", None) is None: + return ring.unattached() return ring.since(0) mcp.resource( @@ -312,12 +334,19 @@ async def read_events() -> dict[str, t.Any]: description="Buffered tmux control-mode notifications", )(read_events) - async def poll_events(since: int = 0) -> dict[str, t.Any]: + async def poll_events( + since: int = 0, + target: str | None = None, + ) -> dict[str, t.Any]: """Return tmux events with sequence number greater than *since*. The response ``cursor`` is the latest sequence number; pass it back as - ``since`` next call to receive only newer events. + ``since`` next call to receive only newer events. With + ``source="output"``, pass ``target`` on the first call so the control + client can attach before the ring starts draining raw ``%output``. """ + if source == "output": + await _ensure_output_attached(engine, target) return ring.since(since) tool = FunctionTool.from_function( @@ -409,6 +438,28 @@ async def _ensure_attached(engine: _StreamEngine, session_id: str) -> None: engine._attached_session = session_id +async def _ensure_output_attached( + engine: _StreamEngine, + target: str | None, +) -> None: + """Ensure raw ``%output`` subscribers have an attached control client.""" + if target is None: + if getattr(engine, "_attached_session", None) is not None: + return + from libtmux.experimental.mcp.vocabulary._resolve import raise_target_hint + + raise_target_hint(_OUTPUT_ATTACH_MESSAGE) + + from libtmux.experimental.mcp.target_resolver import resolve_target + from libtmux.experimental.mcp.vocabulary._resolve import ( + reject_relative_special, + session_id_of, + ) + + reject_relative_special(resolve_target(target)) + await _ensure_attached(engine, await session_id_of(engine, target, None)) + + async def await_pane_output( engine: _StreamEngine, target: str, diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 5fe7bdaae..93c5159ad 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -8,6 +8,7 @@ import asyncio import contextlib +import json import typing as t import pytest @@ -212,6 +213,7 @@ def __init__( ) -> None: self._raw = raw self.calls: list[tuple[str, ...]] = [] + self.subscriptions = 0 self.dropped_notifications = 0 self._attached_session: str | None = None self._attach_returncode = attach_returncode @@ -250,6 +252,7 @@ async def run_batch( async def subscribe(self) -> AsyncIterator[ControlNotification]: """Yield the fixed notification sequence, then bump the drop counter.""" + self.subscriptions += 1 for raw in self._raw: yield ControlNotification.parse(raw) self.dropped_notifications += self._dropped_after @@ -293,6 +296,63 @@ async def main() -> dict[str, t.Any]: assert [event["kind"] for event in data["events"]] == ["window-add", "window-close"] +def test_push_output_source_requires_attach_target() -> None: + """Raw %output streams need a target so the control client can attach.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + InstrumentedEngine((b"%output %1 hi",)), + events="push", + event_source="output", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> str: + async with fastmcp.Client(server) as client: + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "watch_events", + {"kinds": ["output"], "max_events": 1, "timeout": 0.2}, + ) + return str(exc_info.value) + + assert "requires target" in asyncio.run(main()) + + +def test_push_output_source_attaches_target_session() -> None: + """watch_events(target=...) attaches before subscribing to raw %output.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + engine = InstrumentedEngine((b"%output %1 hi",)) + server = build_async_server( + engine, + events="push", + event_source="output", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "watch_events", + { + "target": "%1", + "kinds": ["output"], + "max_events": 1, + "timeout": 2.0, + }, + ) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert data["count"] == 1 + assert data["events"][0]["raw"] == "%output %1 hi" + assert ("display-message", "-t", "%1", "-p", "#{session_id}") in engine.calls + assert ("attach-session", "-t", "$1") in engine.calls + + def test_pull_buffers_events() -> None: """poll_events drains the background ring buffer with a cursor.""" from libtmux.experimental.mcp.fastmcp_adapter import build_async_server @@ -316,6 +376,90 @@ async def main() -> dict[str, t.Any]: assert data["cursor"] == 3 +def test_pull_output_source_attaches_before_drain() -> None: + """poll_events(target=...) attaches before starting the output event ring.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + engine = InstrumentedEngine((b"%output %1 hi",)) + server = build_async_server( + engine, + events="pull", + event_source="output", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + await client.call_tool("poll_events", {"since": 0, "target": "%1"}) + await asyncio.sleep(0.05) + result = await client.call_tool("poll_events", {"since": 0}) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert data["events"][0]["raw"] == "%output %1 hi" + assert ("display-message", "-t", "%1", "-p", "#{session_id}") in engine.calls + assert ("attach-session", "-t", "$1") in engine.calls + + +def test_pull_output_resource_reports_missing_attach_target() -> None: + """The tmux://events resource must not silently drain raw output unattached.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + engine = InstrumentedEngine((b"%output %1 hi",)) + server = build_async_server( + engine, + events="pull", + event_source="output", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + contents = await client.read_resource("tmux://events") + text = "".join(getattr(item, "text", "") for item in contents) + return t.cast("dict[str, t.Any]", json.loads(text)) + + data = asyncio.run(main()) + assert data["events"] == [] + assert data["cursor"] == 0 + assert "requires target" in data["error"] + assert engine.subscriptions == 0 + + +def test_subscription_source_does_not_attach_for_watch_events() -> None: + """Subscription event streams keep their refresh-client behavior.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + engine = InstrumentedEngine(_STREAM) + server = build_async_server( + engine, + events="push", + event_source="subscription", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "watch_events", + { + "kinds": ["window-add"], + "max_events": 1, + "timeout": 2.0, + "subscriptions": ["demo:@1:#{window_id}"], + }, + ) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert data["count"] == 1 + assert ("refresh-client", "-B", "demo:@1:#{window_id}") in engine.calls + assert not [call for call in engine.calls if call and call[0] == "attach-session"] + + def test_both_registers_push_and_pull() -> None: """events='both' exposes both mechanisms.""" from libtmux.experimental.mcp.fastmcp_adapter import build_async_server diff --git a/tests/experimental/mcp/test_monitor_live.py b/tests/experimental/mcp/test_monitor_live.py index 4f55d5805..9b02a2afb 100644 --- a/tests/experimental/mcp/test_monitor_live.py +++ b/tests/experimental/mcp/test_monitor_live.py @@ -71,3 +71,58 @@ async def produce() -> None: assert data.reason in ("settled", "byte_cap") assert "MONITOR_OK" in data.captured_text assert data.frame_count >= 1 + + +def test_watch_events_output_source_captures_real_output(session: Session) -> None: + """watch_events(target=...) attaches and streams a real pane's %output.""" + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = session.server + pane = session.active_window.active_pane + assert pane is not None + pane_id = pane.pane_id + assert pane_id is not None + + async def main() -> t.Any: + async with AsyncControlModeEngine.for_server(server) as engine: + mcp = build_async_server( + engine, + events="push", + event_source="output", + include_operations=False, + include_plan_tools=False, + ) + async with fastmcp.Client(mcp) as client: + + async def produce() -> None: + await asyncio.sleep(0.3) + await engine.run( + CommandRequest.from_args( + "send-keys", + "-t", + pane_id, + "echo WATCH_OK", + "Enter", + ), + ) + + producer = asyncio.ensure_future(produce()) + try: + result = await client.call_tool( + "watch_events", + { + "target": pane_id, + "kinds": ["output"], + "max_events": 1, + "timeout": 10.0, + }, + ) + finally: + await producer + return result.data + + data = asyncio.run(main()) + assert data["count"] == 1 + assert data["events"][0]["kind"] == "output" + assert "WATCH_OK" in data["events"][0]["raw"] From 16c4c05d07550f2bef02d8768c7440c0bf0f68fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:57:54 -0500 Subject: [PATCH 213/223] Agents(feat[cli]): Add tmux console why: Agent monitoring already has live state, hooks, and synchronization primitives, but users need a tmux-like command that boots or reattaches the agent interface without manual setup. what: - Add top-level libtmux.agents module and libtmux-agents console script - Build the managed console session through the declarative workspace engine - Add status, hooks, wait, and send CLI verbs backed by existing agent state - Document the console workflow and cover detached boot/status behavior --- CHANGES | 19 + docs/experimental.md | 43 ++ pyproject.toml | 1 + src/libtmux/agents/__init__.py | 17 + src/libtmux/agents/__main__.py | 7 + src/libtmux/agents/cli.py | 740 ++++++++++++++++++++++++++ tests/experimental/agents/test_cli.py | 274 ++++++++++ 7 files changed, 1101 insertions(+) create mode 100644 src/libtmux/agents/__init__.py create mode 100644 src/libtmux/agents/__main__.py create mode 100644 src/libtmux/agents/cli.py create mode 100644 tests/experimental/agents/test_cli.py diff --git a/CHANGES b/CHANGES index d98c77996..6b256f23c 100644 --- a/CHANGES +++ b/CHANGES @@ -47,6 +47,25 @@ _Notes on the upcoming release will go here._ ### What's new +#### Agent console session (#692) + +`python -m libtmux.agents` now boots a tmux-native agent console. Running the +module with no subcommand creates the managed `libtmux-agents` session when it +does not exist, then attaches or switches the current tmux client to it; `start` +is the explicit form, and `attach` or `att` reconnect to an already-running +console. The session itself is declared with +{class}`~libtmux.experimental.workspace.ir.Workspace`, so creation uses the same +chainable workspace compiler and folded tmux dispatch path as other declarative +workspace builds. + +The console includes a long-lived `monitor` pane backed by +{class}`~libtmux.experimental.agents.monitor.AgentMonitor` and an interactive +shell pane for orchestration work. `status` and `list --json` read the +monitor's persisted JSON store directly, so a caller can inspect the latest +agent snapshot without making a live tmux call. The command also exposes hook +installation plus `wait` and `send` wrappers over the existing in-process agent +synchronization primitives. + #### Agent-state monitor (#692) `libtmux.experimental.agents` is a live agent-state monitor diff --git a/docs/experimental.md b/docs/experimental.md index e28158955..9ee1c3283 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -227,6 +227,49 @@ async def main() -> None: asyncio.run(main()) ``` +### Agent console + +For an interactive tmux-native view, run the top-level module: + +```console +$ python -m libtmux.agents +``` + +With no subcommand, the module behaves like tmux: it creates the managed +`libtmux-agents` session if needed, then attaches to it (or switches the current +tmux client when already inside tmux). The explicit `start` command has the +same behavior, while `attach` and `att` reconnect to an existing console without +creating one. + +```console +$ python -m libtmux.agents attach +``` + +The managed session contains a monitor pane and an interactive shell pane. The +monitor writes a JSON snapshot under `$XDG_STATE_HOME/libtmux/agents/` (or +`~/.local/state/libtmux/agents/`), so status commands can render the latest +known state without contacting tmux: + +```console +$ python -m libtmux.agents status +``` + +Machine-readable callers can use the `list` alias: + +```console +$ python -m libtmux.agents list --json +``` + +Socket and session selectors mirror tmux's own flags. For example, this starts +or attaches a console on a named tmux socket: + +```console +$ python -m libtmux.agents -L work-agents -s libtmux-agents +``` + +The same module also exposes operational shortcuts over the in-process monitor: +`hooks status`, `hooks install`, `wait `, and `send `. + ### Installing agent hooks Before a coding agent can report state, its lifecycle hooks must be installed. diff --git a/pyproject.toml b/pyproject.toml index fe1c1eacf..f34619177 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ libtmux = "libtmux.pytest_plugin" # `main` prints an install hint and exits non-zero when fastmcp is absent. libtmux-engine-mcp = "libtmux.experimental.mcp:main" libtmux-agent-emit = "libtmux.experimental.agents.hooks.emit:main" +libtmux-agents = "libtmux.agents.cli:main" [project.optional-dependencies] mcp = [ diff --git a/src/libtmux/agents/__init__.py b/src/libtmux/agents/__init__.py new file mode 100644 index 000000000..3672af2f5 --- /dev/null +++ b/src/libtmux/agents/__init__.py @@ -0,0 +1,17 @@ +"""Top-level agent console entry point for ``python -m libtmux.agents``. + +The public import is intentionally small: this package exposes the command-line +entry point while the underlying monitor and synchronization primitives remain +in :mod:`libtmux.experimental.agents`. + +Examples +-------- +>>> callable(main) +True +""" + +from __future__ import annotations + +from libtmux.agents.cli import main + +__all__ = ("main",) diff --git a/src/libtmux/agents/__main__.py b/src/libtmux/agents/__main__.py new file mode 100644 index 000000000..0d35a9f10 --- /dev/null +++ b/src/libtmux/agents/__main__.py @@ -0,0 +1,7 @@ +"""Run the libtmux agent console.""" + +from __future__ import annotations + +from libtmux.agents.cli import main + +raise SystemExit(main()) diff --git a/src/libtmux/agents/cli.py b/src/libtmux/agents/cli.py new file mode 100644 index 000000000..240f51a6f --- /dev/null +++ b/src/libtmux/agents/cli.py @@ -0,0 +1,740 @@ +"""Command-line interface for a tmux-native agent console.""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import dataclasses +import json +import os +import pathlib +import re +import shlex +import signal +import subprocess +import sys +import typing as t +from dataclasses import dataclass + +import libtmux +from libtmux.experimental.agents.drive import send_to_agent +from libtmux.experimental.agents.hooks import registry as hook_registry +from libtmux.experimental.agents.hud import HudRenderer +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import Agent, AgentState +from libtmux.experimental.agents.statusline import paint_status_line +from libtmux.experimental.agents.store import AgentStore, JsonFile +from libtmux.experimental.agents.wait import wait_for_agent_state +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.engines.subprocess import SubprocessEngine +from libtmux.experimental.ops._types import NameRef +from libtmux.experimental.workspace import Pane, Window, Workspace + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +DEFAULT_SESSION_NAME = "libtmux-agents" +"""Default tmux session name for the agent console.""" + +DEFAULT_WINDOW_NAME = "agents" +"""Default window name inside the console session.""" + + +@dataclass(frozen=True) +class AgentConsoleConfig: + """Connection and session settings for the agent console. + + Parameters + ---------- + session_name : str + Managed tmux session name. + socket_name, socket_path : str or None + tmux server selectors. ``socket_path`` wins when both are present. + state_path : pathlib.Path or None + JSON store read by ``status`` and written by ``monitor``. + detached : bool + Build or check the session without attaching the current client. + json : bool + Prefer machine-readable output for status-like commands. + hud : bool + Enable the floating HUD managed by :class:`AgentMonitor`. + status_line : bool + Paint a session-scoped ``status-right`` summary from the monitor. + + Examples + -------- + >>> AgentConsoleConfig(session_name="demo").resolved_state_path.name + 'demo.json' + """ + + session_name: str = DEFAULT_SESSION_NAME + socket_name: str | None = None + socket_path: str | pathlib.Path | None = None + state_path: pathlib.Path | None = None + detached: bool = False + json: bool = False + hud: bool = False + status_line: bool = True + + @property + def resolved_state_path(self) -> pathlib.Path: + """Return the explicit state path or the XDG-derived default. + + Examples + -------- + >>> AgentConsoleConfig(session_name="my agents").resolved_state_path.name + 'my-agents.json' + """ + if self.state_path is not None: + return pathlib.Path(self.state_path) + return default_state_path(self.session_name) + + +@dataclass(frozen=True) +class ConsoleResult: + """Result data for ``start`` and ``attach``. + + Examples + -------- + >>> ConsoleResult(False, "agents", pathlib.Path("x.json"), False).returncode + 0 + """ + + created: bool + session_name: str + state_path: pathlib.Path + attached: bool + returncode: int = 0 + + +def _slug(value: str) -> str: + """Return a conservative filesystem slug.""" + slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-") + return slug or DEFAULT_SESSION_NAME + + +def default_state_path(session_name: str = DEFAULT_SESSION_NAME) -> pathlib.Path: + """Return the default JSON store path for *session_name*. + + The path follows ``$XDG_STATE_HOME`` when present and otherwise falls back + to ``~/.local/state``. + + Examples + -------- + >>> default_state_path("my agents").name + 'my-agents.json' + """ + root = os.environ.get("XDG_STATE_HOME") + base = pathlib.Path(root) if root else pathlib.Path.home() / ".local" / "state" + return base / "libtmux" / "agents" / f"{_slug(session_name)}.json" + + +def _socket_args(config: AgentConsoleConfig) -> list[str]: + """Render tmux socket selectors as argv tokens.""" + if config.socket_path is not None: + return ["-S", str(config.socket_path)] + if config.socket_name is not None: + return ["-L", config.socket_name] + return [] + + +def _server(config: AgentConsoleConfig) -> libtmux.Server: + """Build a libtmux server object for *config*.""" + if config.socket_path is not None: + return libtmux.Server(socket_path=config.socket_path) + return libtmux.Server(socket_name=config.socket_name) + + +def _monitor_command(config: AgentConsoleConfig) -> str: + """Return the shell command run in the monitor pane.""" + argv = [ + sys.executable, + "-m", + "libtmux.agents", + *(_socket_args(config)), + "--session-name", + config.session_name, + "--state-path", + str(config.resolved_state_path), + "monitor", + ] + if config.hud: + argv.append("--hud") + if not config.status_line: + argv.append("--no-status-line") + return shlex.join(argv) + + +def _interactive_shell() -> str: + """Return the user's shell command, falling back to ``/bin/sh``.""" + return os.environ.get("SHELL") or "/bin/sh" + + +def build_console_workspace(config: AgentConsoleConfig) -> Workspace: + """Declare the managed tmux session used by ``start``. + + The monitor pane starts from a sent command so the build can reuse the + existing workspace compiler and fold the session creation plan. + + Examples + -------- + >>> cfg = AgentConsoleConfig(session_name="demo", state_path=pathlib.Path("s.json")) + >>> ws = build_console_workspace(cfg) + >>> (ws.name, ws.windows[0].name, len(ws.windows[0].panes)) + ('demo', 'agents', 2) + """ + return Workspace( + name=config.session_name, + options={"status": "on"}, + windows=[ + Window( + name=DEFAULT_WINDOW_NAME, + layout="main-horizontal", + panes=[ + Pane(run=_monitor_command(config)), + Pane(shell=_interactive_shell(), focus=True), + ], + ) + ], + ) + + +def _attach_client(config: AgentConsoleConfig) -> int: + """Attach or switch the current tmux client to the configured session.""" + if config.detached: + return 0 + + cmd = ["tmux", *_socket_args(config)] + if os.environ.get("TMUX"): + cmd.extend(("switch-client", "-t", config.session_name)) + else: + cmd.extend(("attach-session", "-t", config.session_name)) + try: + return subprocess.run(cmd, check=False).returncode + except FileNotFoundError: + print("tmux not found", file=sys.stderr) + return 127 + + +def start(config: AgentConsoleConfig) -> ConsoleResult: + """Create the console session if needed, then attach or switch to it. + + Examples + -------- + >>> import inspect + >>> "config" in inspect.signature(start).parameters + True + """ + state_path = config.resolved_state_path + server = _server(config) + created = False + if not server.has_session(config.session_name): + workspace = build_console_workspace(config) + result = workspace.build( + SubprocessEngine.for_server(server), + preflight=False, + ) + if not result.ok: + stderr = "\n".join( + line + for command_result in result.results + for line in command_result.stderr + ) + msg = stderr or f"failed to build {config.session_name!r}" + raise RuntimeError(msg) + created = True + + returncode = _attach_client(config) + return ConsoleResult( + created=created, + session_name=config.session_name, + state_path=state_path, + attached=not config.detached and returncode == 0, + returncode=returncode, + ) + + +def attach(config: AgentConsoleConfig) -> ConsoleResult: + """Attach or switch to an already-running console session. + + Examples + -------- + >>> import inspect + >>> "config" in inspect.signature(attach).parameters + True + """ + state_path = config.resolved_state_path + server = _server(config) + if not server.has_session(config.session_name): + print( + f"agent console {config.session_name!r} is not running; " + "run `python -m libtmux.agents start` first", + file=sys.stderr, + ) + return ConsoleResult(False, config.session_name, state_path, False, 1) + + returncode = _attach_client(config) + return ConsoleResult( + created=False, + session_name=config.session_name, + state_path=state_path, + attached=not config.detached and returncode == 0, + returncode=returncode, + ) + + +def _store_from_path(path: pathlib.Path) -> AgentStore: + """Load an :class:`AgentStore` from *path*, returning empty when absent.""" + data = JsonFile(path).load() + return AgentStore.from_dict(data) if data else AgentStore() + + +def _agent_payload(agent: Agent) -> dict[str, t.Any]: + """Serialize one agent for JSON output.""" + return dataclasses.asdict(agent) | {"state": agent.state.value} + + +def _sorted_agents(store: AgentStore) -> list[Agent]: + """Return store agents in stable pane-id order.""" + return sorted(store.agents.values(), key=lambda agent: agent.pane_id) + + +def status(config: AgentConsoleConfig) -> int: + """Print the persisted agent snapshot without contacting tmux. + + Examples + -------- + >>> import inspect + >>> "config" in inspect.signature(status).parameters + True + """ + store = _store_from_path(config.resolved_state_path) + agents = _sorted_agents(store) + if config.json: + print(json.dumps([_agent_payload(agent) for agent in agents])) + return 0 + if not agents: + print("no agents") + return 0 + print(f"{'pane':<8} {'state':<14} {'agent':<12} {'alive':<5} source") + for agent in agents: + name = agent.name or "-" + alive = "yes" if agent.alive else "no" + print( + f"{agent.pane_id:<8} {agent.state.value:<14} " + f"{name:<12} {alive:<5} {agent.source}" + ) + return 0 + + +def _render_dashboard(monitor: AgentMonitor) -> str: + """Render a full-screen monitor dashboard from the current store.""" + store = AgentStore( + agents={agent.pane_id: agent for agent in monitor.agents}, + stamps={}, + ) + return HudRenderer().render(store) + + +async def _redraw_monitor( + engine: AsyncControlModeEngine, + monitor: AgentMonitor, + config: AgentConsoleConfig, +) -> None: + """Paint stdout and the session status line from the monitor snapshot.""" + print("\033[H\033[J", end="") + print(_render_dashboard(monitor), flush=True) + if config.status_line: + with contextlib.suppress(Exception): + await paint_status_line( + engine, + monitor, + target=NameRef(config.session_name, exact=True), + ) + + +async def run_monitor(config: AgentConsoleConfig) -> int: + """Run the long-lived monitor loop inside the console session. + + Examples + -------- + >>> import inspect + >>> "config" in inspect.signature(run_monitor).parameters + True + """ + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for item in (signal.SIGINT, signal.SIGTERM): + with contextlib.suppress(NotImplementedError, RuntimeError): + loop.add_signal_handler(item, stop.set) + + server = _server(config) + async with AsyncControlModeEngine.for_server(server) as engine: + monitor = AgentMonitor( + engine, + sink=JsonFile(config.resolved_state_path), + hud=config.hud, + ) + + redraws: set[asyncio.Task[None]] = set() + + def changed(_: t.Any) -> None: + task = loop.create_task(_redraw_monitor(engine, monitor, config)) + redraws.add(task) + task.add_done_callback(redraws.discard) + + monitor.add_transition_observer(changed) + await monitor.start() + await _redraw_monitor(engine, monitor, config) + try: + await stop.wait() + finally: + await monitor.stop() + return 0 + + +def _parse_states(raw: str) -> tuple[AgentState, ...]: + """Parse a comma-separated state list.""" + return tuple( + AgentState.from_signal(part) + for part in (item.strip() for item in raw.split(",")) + if part + ) + + +async def _wait_command(args: argparse.Namespace, config: AgentConsoleConfig) -> int: + """Run the ``wait`` command.""" + async with AsyncControlModeEngine.for_server(_server(config)) as engine: + monitor = AgentMonitor(engine, sink=JsonFile(config.resolved_state_path)) + await monitor.start() + try: + outcome = await wait_for_agent_state( + monitor, + args.pane_id, + _parse_states(args.state), + timeout=args.timeout, + ) + finally: + await monitor.stop() + if args.json: + print( + json.dumps( + { + "pane_id": outcome.pane_id, + "reason": outcome.reason.value, + "state": outcome.agent.state.value if outcome.agent else None, + } + ) + ) + else: + state = outcome.agent.state.value if outcome.agent else "-" + print(f"{outcome.pane_id} {outcome.reason.value} {state}") + return 0 if outcome.reached else 1 + + +async def _send_command(args: argparse.Namespace, config: AgentConsoleConfig) -> int: + """Run the ``send`` command.""" + text = " ".join(args.text) + async with AsyncControlModeEngine.for_server(_server(config)) as engine: + monitor = AgentMonitor(engine, sink=JsonFile(config.resolved_state_path)) + await monitor.start() + try: + outcome = await send_to_agent( + monitor, + args.pane_id, + text, + wait_ready=not args.no_wait, + timeout=args.timeout, + ) + finally: + await monitor.stop() + wait = outcome.wait + if args.json: + print( + json.dumps( + { + "pane_id": outcome.pane_id, + "sent": outcome.sent, + "deduplicated": outcome.deduplicated, + "wait": wait.reason.value if wait is not None else None, + } + ) + ) + else: + print(f"{outcome.pane_id} sent={str(outcome.sent).lower()}") + return 0 if outcome.sent else 1 + + +def _selected_hooks(target: str) -> list[t.Any]: + """Return the hook installers selected by *target*.""" + hooks = hook_registry.registry() + if target == "all": + return hooks + selected = [hook for hook in hooks if hook.name == target] + if selected: + return selected + print(f"unknown hook target {target!r}", file=sys.stderr) + return [] + + +def _hooks_command(args: argparse.Namespace) -> int: + """Run the ``hooks`` subcommand.""" + target = args.target or "all" + hooks = _selected_hooks(target) + if not hooks: + return 1 + + if args.hooks_action == "status": + print(f"{'hook':<12} {'status':<10} detected") + for hook in hooks: + detected = "yes" if hook.detect() else "no" + print(f"{hook.name:<12} {hook.status():<10} {detected}") + return 0 + + for hook in hooks: + hook.install() + print(f"installed {hook.name}") + return 0 + + +def _add_common(parser: argparse.ArgumentParser, *, suppress: bool = False) -> None: + """Add shared tmux connection options to *parser*.""" + default: t.Any = argparse.SUPPRESS if suppress else None + parser.add_argument("-L", "--socket-name", default=default) + parser.add_argument("-S", "--socket-path", default=default) + parser.add_argument( + "-s", + "--session-name", + default=argparse.SUPPRESS if suppress else DEFAULT_SESSION_NAME, + ) + parser.add_argument( + "--state-path", + type=pathlib.Path, + default=default, + ) + + +def _add_detach(parser: argparse.ArgumentParser, *, suppress: bool = False) -> None: + """Add attach-control flags to *parser*.""" + parser.add_argument( + "-d", + "--detached", + action="store_true", + default=argparse.SUPPRESS if suppress else False, + help="create/check the session without attaching the current client", + ) + + +def _add_monitor_flags( + parser: argparse.ArgumentParser, + *, + suppress: bool = False, +) -> None: + """Add monitor display flags to *parser*.""" + parser.add_argument( + "--hud", + action="store_true", + default=argparse.SUPPRESS if suppress else False, + help="show a floating agent HUD when tmux supports it", + ) + parser.add_argument( + "--no-status-line", + action="store_false", + dest="status_line", + default=argparse.SUPPRESS if suppress else True, + help="do not paint the managed session status line", + ) + + +def _build_parser() -> argparse.ArgumentParser: + """Build the argparse parser. + + Examples + -------- + >>> _build_parser().prog + 'python -m libtmux.agents' + """ + parser = argparse.ArgumentParser( + prog="python -m libtmux.agents", + description="Boot, attach, and inspect the libtmux agent console.", + ) + _add_common(parser) + _add_detach(parser) + _add_monitor_flags(parser) + subcommands = parser.add_subparsers(dest="command") + + start_parser = subcommands.add_parser( + "start", + help="create the console session if needed, then attach", + ) + _add_common(start_parser, suppress=True) + _add_detach(start_parser, suppress=True) + _add_monitor_flags(start_parser, suppress=True) + start_parser.set_defaults(command="start") + + attach_parser = subcommands.add_parser( + "attach", + aliases=["att"], + help="attach to an already-running console", + ) + _add_common(attach_parser, suppress=True) + _add_detach(attach_parser, suppress=True) + attach_parser.set_defaults(command="attach") + + monitor_parser = subcommands.add_parser( + "monitor", + help="run the long-lived monitor pane", + ) + _add_common(monitor_parser, suppress=True) + _add_monitor_flags(monitor_parser, suppress=True) + monitor_parser.set_defaults(command="monitor") + + status_parser = subcommands.add_parser( + "status", + aliases=["list"], + help="print the persisted agent snapshot without tmux calls", + ) + _add_common(status_parser, suppress=True) + status_parser.add_argument("--json", action="store_true", default=False) + status_parser.set_defaults(command="status") + + hooks_parser = subcommands.add_parser("hooks", help="manage agent hooks") + hooks_subcommands = hooks_parser.add_subparsers(dest="hooks_action", required=True) + hooks_status = hooks_subcommands.add_parser("status", help="show hook status") + hooks_status.add_argument("target", nargs="?", default="all") + hooks_status.set_defaults(command="hooks") + hooks_install = hooks_subcommands.add_parser("install", help="install hooks") + hooks_install.add_argument("target", nargs="?", default="all") + hooks_install.set_defaults(command="hooks") + + wait_parser = subcommands.add_parser( + "wait", + help="wait until a pane's agent reaches a state", + ) + _add_common(wait_parser, suppress=True) + wait_parser.add_argument("pane_id") + wait_parser.add_argument("--state", default="awaiting_input,done,idle") + wait_parser.add_argument("--timeout", type=float, default=None) + wait_parser.add_argument("--json", action="store_true", default=False) + wait_parser.set_defaults(command="wait") + + send_parser = subcommands.add_parser( + "send", + help="send text to an agent pane when it is ready", + ) + _add_common(send_parser, suppress=True) + send_parser.add_argument("pane_id") + send_parser.add_argument("text", nargs=argparse.REMAINDER) + send_parser.add_argument("--timeout", type=float, default=None) + send_parser.add_argument("--no-wait", action="store_true", default=False) + send_parser.add_argument("--json", action="store_true", default=False) + send_parser.set_defaults(command="send") + + help_parser = subcommands.add_parser("help", help="show command help") + help_parser.add_argument("topic", nargs="?") + help_parser.set_defaults(command="help") + return parser + + +def _fill_defaults(args: argparse.Namespace) -> argparse.Namespace: + """Backfill shared defaults suppressed by nested parsers.""" + defaults: dict[str, t.Any] = { + "command": "start", + "socket_name": None, + "socket_path": None, + "session_name": DEFAULT_SESSION_NAME, + "state_path": None, + "detached": False, + "hud": False, + "status_line": True, + "json": False, + } + for key, value in defaults.items(): + if not hasattr(args, key) or (getattr(args, key) is None and key == "command"): + setattr(args, key, value) + return args + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse CLI args, defaulting an absent subcommand to ``start``. + + Examples + -------- + >>> parse_args([]).command + 'start' + >>> parse_args(["att", "--detached"]).command + 'attach' + """ + parser = _build_parser() + items = sys.argv[1:] if argv is None else list(argv) + args = parser.parse_args(items) + return _fill_defaults(args) + + +def _config_from_args(args: argparse.Namespace) -> AgentConsoleConfig: + """Build :class:`AgentConsoleConfig` from argparse output.""" + return AgentConsoleConfig( + session_name=args.session_name, + socket_name=args.socket_name, + socket_path=args.socket_path, + state_path=args.state_path, + detached=args.detached, + json=args.json, + hud=args.hud, + status_line=args.status_line, + ) + + +def _print_command_help(parser: argparse.ArgumentParser, topic: str | None) -> int: + """Print top-level or subcommand help.""" + if topic is None: + parser.print_help() + return 0 + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + command = action.choices.get(topic) + if command is not None: + command.print_help() + return 0 + print(f"unknown help topic {topic!r}", file=sys.stderr) + return 1 + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the ``libtmux.agents`` command. + + Examples + -------- + >>> callable(main) + True + """ + parser = _build_parser() + items = sys.argv[1:] if argv is None else list(argv) + args = _fill_defaults(parser.parse_args(items)) + + if args.command == "help": + return _print_command_help(parser, args.topic) + + config = _config_from_args(args) + if args.command == "start": + return start(config).returncode + if args.command == "attach": + return attach(config).returncode + if args.command == "monitor": + return asyncio.run(run_monitor(config)) + if args.command == "status": + return status(config) + if args.command == "hooks": + return _hooks_command(args) + if args.command == "wait": + return asyncio.run(_wait_command(args, config)) + if args.command == "send": + if not args.text: + print("send requires text", file=sys.stderr) + return 2 + return asyncio.run(_send_command(args, config)) + + parser.error(f"unknown command {args.command!r}") + return 2 diff --git a/tests/experimental/agents/test_cli.py b/tests/experimental/agents/test_cli.py new file mode 100644 index 000000000..4e3583f4b --- /dev/null +++ b/tests/experimental/agents/test_cli.py @@ -0,0 +1,274 @@ +"""Tests for the top-level ``python -m libtmux.agents`` console.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import subprocess +import sys +import typing as t + +import pytest + +import libtmux +from libtmux.agents import cli +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.state import Agent, AgentState +from libtmux.experimental.agents.store import AgentStore, JsonFile +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.test.random import namer + +if t.TYPE_CHECKING: + from pathlib import Path + + from libtmux.session import Session + + +def test_module_help_smoke() -> None: + """``python -m libtmux.agents --help`` exposes the console verbs.""" + process = subprocess.run( + [sys.executable, "-m", "libtmux.agents", "--help"], + capture_output=True, + text=True, + check=False, + ) + + assert process.returncode == 0 + assert "start" in process.stdout + assert "attach" in process.stdout + assert "monitor" in process.stdout + + +def test_parse_no_args_defaults_to_start() -> None: + """No subcommand behaves like tmux: create or attach the console.""" + args = cli.parse_args([]) + + assert args.command == "start" + assert args.session_name == cli.DEFAULT_SESSION_NAME + + +def test_parse_attach_alias() -> None: + """``att`` is a short alias for attaching to the running console.""" + args = cli.parse_args(["att", "--detached"]) + + assert args.command == "attach" + assert args.detached is True + + +def test_default_state_path_honors_xdg( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """The default store path lives under XDG state with a filesystem-safe slug.""" + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + + path = cli.default_state_path("libtmux agents / demo") + + assert path == tmp_path / "libtmux" / "agents" / "libtmux-agents-demo.json" + + +def test_status_reads_store_without_tmux( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """``status`` reads persisted JSON directly, with no live tmux call.""" + state_path = tmp_path / "agents.json" + store = AgentStore( + agents={ + "%1": Agent( + pane_id="%1", + key="%1", + name="claude", + state=AgentState.RUNNING, + since=1.5, + source="option", + pid=123, + alive=True, + ) + }, + ) + JsonFile(state_path).save(store.to_dict()) + + def fail_server(config: cli.AgentConsoleConfig) -> libtmux.Server: + pytest.fail("status must not contact tmux") + + monkeypatch.setattr(cli, "_server", fail_server) + + assert cli.main(["--state-path", str(state_path), "status"]) == 0 + + out = capsys.readouterr().out + assert "%1" in out + assert "claude" in out + assert "running" in out + + +def test_status_json_reads_store_alias( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """``list --json`` is a machine-readable alias over the same store.""" + state_path = tmp_path / "agents.json" + store = AgentStore( + agents={ + "%2": Agent( + pane_id="%2", + key="%2", + name=None, + state=AgentState.DONE, + since=2.0, + source="option", + pid=None, + alive=True, + ) + }, + ) + JsonFile(state_path).save(store.to_dict()) + + assert cli.main(["--state-path", str(state_path), "list", "--json"]) == 0 + + payload = json.loads(capsys.readouterr().out) + assert payload == [ + { + "pane_id": "%2", + "key": "%2", + "name": None, + "state": "done", + "since": 2.0, + "source": "option", + "pid": None, + "alive": True, + } + ] + + +def test_hooks_status_and_install_use_registry( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Hook commands are a thin layer over the existing hook registry.""" + installed: list[str] = [] + + class FakeHook: + name = "fake" + + def detect(self) -> bool: + return True + + def install(self) -> None: + installed.append(self.name) + + def uninstall(self) -> None: + pytest.fail("uninstall should not be called") + + def status(self) -> str: + return "installed" + + monkeypatch.setattr( + "libtmux.agents.cli.hook_registry.registry", + lambda: [FakeHook()], + ) + + assert cli.main(["hooks", "status"]) == 0 + status_out = capsys.readouterr().out + assert "fake" in status_out + assert "installed" in status_out + assert "yes" in status_out + + assert cli.main(["hooks", "install", "fake"]) == 0 + assert installed == ["fake"] + + +def test_start_detached_builds_and_reattaches(tmp_path: Path) -> None: + """``start`` builds the console once and reuses it on the second call.""" + socket = f"libtmux_agents_cli_{next(namer)}" + session_name = f"agents_{next(namer)}" + state_path = tmp_path / "agents.json" + server = libtmux.Server(socket_name=socket) + config = cli.AgentConsoleConfig( + socket_name=socket, + session_name=session_name, + state_path=state_path, + detached=True, + ) + + try: + first = cli.start(config) + assert first.returncode == 0 + assert first.created is True + assert first.attached is False + assert first.state_path == state_path + assert server.has_session(session_name) + + session = server.sessions.get(session_name=session_name) + assert session is not None + assert [window.window_name for window in session.windows] == ["agents"] + assert len(session.windows[0].panes) == 2 + + second = cli.start(config) + assert second.returncode == 0 + assert second.created is False + assert server.has_session(session_name) + finally: + with contextlib.suppress(Exception): + server.kill() + + +def test_attach_detached_requires_existing_session( + capsys: pytest.CaptureFixture[str], +) -> None: + """``attach`` only attaches an already-running console.""" + socket = f"libtmux_agents_attach_{next(namer)}" + session_name = f"agents_{next(namer)}" + server = libtmux.Server(socket_name=socket) + config = cli.AgentConsoleConfig( + socket_name=socket, + session_name=session_name, + detached=True, + ) + + try: + missing = cli.attach(config) + assert missing.returncode == 1 + assert "start" in capsys.readouterr().err + + server.new_session(session_name=session_name, detach=True) + present = cli.attach(config) + assert present.returncode == 0 + finally: + with contextlib.suppress(Exception): + server.kill() + + +def test_monitor_persists_live_state(session: Session, tmp_path: Path) -> None: + """A monitor sink writes the same JSON store that the CLI status reads.""" + state_path = tmp_path / "agents.json" + + async def main() -> str: + engine = AsyncControlModeEngine.for_server(session.server) + monitor = AgentMonitor(engine, sink=JsonFile(state_path)) + await monitor.start() + active = session.active_window.active_pane + assert active is not None + pane_id = active.pane_id + assert pane_id is not None + + session.cmd("set-option", "-p", "-t", pane_id, "@agent_state", "running") + observed = "missing" + for _ in range(40): + await asyncio.sleep(0.1) + match = {a.pane_id: a for a in monitor.agents}.get(pane_id) + if match is not None: + observed = match.state.value + if observed == "running": + break + await monitor.stop() + await engine.aclose() + return observed + + assert asyncio.run(main()) == "running" + data = JsonFile(state_path).load() + assert data is not None + store = AgentStore.from_dict(data) + assert [agent.state for agent in store.agents.values()] == [AgentState.RUNNING] From d10eecbb77d819714d3ad47999df769e6102d8f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 06:07:59 -0500 Subject: [PATCH 214/223] Agents(fix[monitor]): Seed console state why: The console dashboard could stay on "(no agents)" because the CLI opened the control-mode connection before registering the monitor subscription, and reconcile did not seed pane options that were already present. what: - Register CLI monitor subscriptions before the first control-mode connection - Include durable @agent_state and @agent_name values in pane reconciliation - Add live and reducer coverage for startup and after-start state changes - Harden the live MCP output test against tmux chunk boundaries --- src/libtmux/agents/cli.py | 41 +++++---- src/libtmux/experimental/agents/monitor.py | 24 +++++- src/libtmux/experimental/agents/tree.py | 2 + tests/experimental/agents/test_cli.py | 55 ++++++++++++ tests/experimental/agents/test_monitor.py | 94 +++++++++++++++++++++ tests/experimental/mcp/test_monitor_live.py | 16 ++-- 6 files changed, 210 insertions(+), 22 deletions(-) diff --git a/src/libtmux/agents/cli.py b/src/libtmux/agents/cli.py index 240f51a6f..586c1f43b 100644 --- a/src/libtmux/agents/cli.py +++ b/src/libtmux/agents/cli.py @@ -371,27 +371,30 @@ async def run_monitor(config: AgentConsoleConfig) -> int: loop.add_signal_handler(item, stop.set) server = _server(config) - async with AsyncControlModeEngine.for_server(server) as engine: - monitor = AgentMonitor( - engine, - sink=JsonFile(config.resolved_state_path), - hud=config.hud, - ) + engine = AsyncControlModeEngine.for_server(server) + monitor = AgentMonitor( + engine, + sink=JsonFile(config.resolved_state_path), + hud=config.hud, + ) - redraws: set[asyncio.Task[None]] = set() + redraws: set[asyncio.Task[None]] = set() - def changed(_: t.Any) -> None: - task = loop.create_task(_redraw_monitor(engine, monitor, config)) - redraws.add(task) - task.add_done_callback(redraws.discard) + def changed(_: t.Any) -> None: + task = loop.create_task(_redraw_monitor(engine, monitor, config)) + redraws.add(task) + task.add_done_callback(redraws.discard) - monitor.add_transition_observer(changed) + monitor.add_transition_observer(changed) + try: await monitor.start() await _redraw_monitor(engine, monitor, config) try: await stop.wait() finally: await monitor.stop() + finally: + await engine.aclose() return 0 @@ -406,8 +409,9 @@ def _parse_states(raw: str) -> tuple[AgentState, ...]: async def _wait_command(args: argparse.Namespace, config: AgentConsoleConfig) -> int: """Run the ``wait`` command.""" - async with AsyncControlModeEngine.for_server(_server(config)) as engine: - monitor = AgentMonitor(engine, sink=JsonFile(config.resolved_state_path)) + engine = AsyncControlModeEngine.for_server(_server(config)) + monitor = AgentMonitor(engine, sink=JsonFile(config.resolved_state_path)) + try: await monitor.start() try: outcome = await wait_for_agent_state( @@ -418,6 +422,8 @@ async def _wait_command(args: argparse.Namespace, config: AgentConsoleConfig) -> ) finally: await monitor.stop() + finally: + await engine.aclose() if args.json: print( json.dumps( @@ -437,8 +443,9 @@ async def _wait_command(args: argparse.Namespace, config: AgentConsoleConfig) -> async def _send_command(args: argparse.Namespace, config: AgentConsoleConfig) -> int: """Run the ``send`` command.""" text = " ".join(args.text) - async with AsyncControlModeEngine.for_server(_server(config)) as engine: - monitor = AgentMonitor(engine, sink=JsonFile(config.resolved_state_path)) + engine = AsyncControlModeEngine.for_server(_server(config)) + monitor = AgentMonitor(engine, sink=JsonFile(config.resolved_state_path)) + try: await monitor.start() try: outcome = await send_to_agent( @@ -450,6 +457,8 @@ async def _send_command(args: argparse.Namespace, config: AgentConsoleConfig) -> ) finally: await monitor.stop() + finally: + await engine.aclose() wait = outcome.wait if args.json: print( diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 35931778b..f338cd9e6 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -26,7 +26,12 @@ from libtmux.experimental.agents.health import is_alive from libtmux.experimental.agents.hud import HudRenderer from libtmux.experimental.agents.merge import MonotonicCounter, Stamp -from libtmux.experimental.agents.signals import SUBSCRIPTION, OptionSignal, OscSignal +from libtmux.experimental.agents.signals import ( + SUBSCRIPTION, + OptionSignal, + OscSignal, + Reading, +) from libtmux.experimental.agents.state import AgentState, AgentTransition from libtmux.experimental.agents.store import ( AgentStore, @@ -586,6 +591,7 @@ async def _reconcile_once(self) -> None: for pane_id, pane in panes_of(snapshot).items() if pane_id != self._hud_pane_id } + self._observe_pane_options(current_panes) _added, removed = diff_panes(self._prev_panes, current_panes) for pane_id in removed: before = self._store.agents.get(pane_id) @@ -599,6 +605,22 @@ async def _reconcile_once(self) -> None: self._prev_panes = dict(current_panes) self._hud_dirty = True + def _observe_pane_options(self, current_panes: dict[str, PaneSnapshot]) -> None: + """Seed/refresh store entries from durable per-pane option values.""" + for pane_id, pane in current_panes.items(): + raw_state = pane.fields.get("@agent_state", "").strip() + if not raw_state: + continue + state = AgentState.from_signal(raw_state) + name = pane.fields.get("@agent_name") or None + current = self._store.agents.get(pane_id) + if current is not None: + if current.source != "option": + continue + if current.state is state and current.name == name: + continue + self._observe(Reading(pane_id, state, name, "option")) + def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: """Refresh tracked agents' ``pid``/``alive`` from the pane tree. diff --git a/src/libtmux/experimental/agents/tree.py b/src/libtmux/experimental/agents/tree.py index fa7b51eaf..8baa4223b 100644 --- a/src/libtmux/experimental/agents/tree.py +++ b/src/libtmux/experimental/agents/tree.py @@ -26,6 +26,8 @@ "pane_pid", "pane_current_command", "pane_title", + "@agent_state", + "@agent_name", ) diff --git a/tests/experimental/agents/test_cli.py b/tests/experimental/agents/test_cli.py index 4e3583f4b..a05011d90 100644 --- a/tests/experimental/agents/test_cli.py +++ b/tests/experimental/agents/test_cli.py @@ -272,3 +272,58 @@ async def main() -> str: assert data is not None store = AgentStore.from_dict(data) assert [agent.state for agent in store.agents.values()] == [AgentState.RUNNING] + + +def test_run_monitor_observes_option_after_start( + session: Session, + tmp_path: Path, +) -> None: + """The CLI monitor installs subscriptions before the first connection.""" + state_path = tmp_path / "agents.json" + + async def main() -> bool: + session_name = session.session_name + assert session_name is not None + task = asyncio.create_task( + cli.run_monitor( + cli.AgentConsoleConfig( + socket_name=session.server.socket_name, + session_name=session_name, + state_path=state_path, + status_line=False, + ) + ) + ) + try: + for _ in range(30): + await asyncio.sleep(0.1) + clients = session.server.cmd( + "list-clients", + "-F", + "#{client_control_mode} #{session_name}", + ).stdout + if any(line == f"1 {session_name}" for line in clients): + break + + pane = session.active_window.active_pane + assert pane is not None + pane_id = pane.pane_id + assert pane_id is not None + session.cmd("set-option", "-p", "-t", pane_id, "@agent_state", "running") + + for _ in range(40): + await asyncio.sleep(0.1) + data = JsonFile(state_path).load() + if not data: + continue + store = AgentStore.from_dict(data) + agent = store.agents.get(pane_id) + if agent is not None and agent.state is AgentState.RUNNING: + return True + return False + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + assert asyncio.run(main()) is True diff --git a/tests/experimental/agents/test_monitor.py b/tests/experimental/agents/test_monitor.py index 5b3b792d5..29882c502 100644 --- a/tests/experimental/agents/test_monitor.py +++ b/tests/experimental/agents/test_monitor.py @@ -7,6 +7,8 @@ from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.store import AgentStore +from libtmux.experimental.agents.tree import PANE_FORMAT from libtmux.experimental.engines.base import CommandRequest, CommandResult from libtmux.experimental.models.snapshots import PaneSnapshot @@ -42,6 +44,56 @@ def add_subscription(self, spec: object) -> None: ... def set_attach_targets(self, ids: object) -> None: ... +class _PaneRowsEngine: + def __init__(self, rows: tuple[str, ...]) -> None: + self.rows = rows + + async def run(self, request: CommandRequest) -> CommandResult: + if request.args[:1] == ("list-panes",): + return CommandResult(cmd=request.args, stdout=self.rows) + return CommandResult(cmd=request.args, stdout=("$0",)) + + async def subscribe(self) -> None: ... + + def add_subscription(self, spec: object) -> None: ... + + def set_attach_targets(self, ids: object) -> None: ... + + +class _MemorySink: + def __init__(self) -> None: + self.data: dict[str, object] | None = None + + def load(self) -> dict[str, object] | None: + return self.data + + def save(self, data: dict[str, object]) -> None: + self.data = data + + +def _pane_row(**values: str) -> str: + """Build one tab-separated pane row in the monitor's requested field order.""" + fields = { + "session_id": "$0", + "session_name": "agents", + "window_id": "@0", + "window_index": "0", + "window_name": "agents", + "window_active": "1", + "pane_id": "%1", + "pane_index": "0", + "pane_active": "1", + "pane_floating_flag": "0", + "pane_pid": str(os.getpid()), + "pane_current_command": "claude", + "pane_title": "", + "@agent_state": "", + "@agent_name": "", + } + fields.update(values) + return "\t".join(fields.get(field, "") for field in PANE_FORMAT) + + def test_primary_session_id_none_when_own_probe_fails() -> None: """A failing own-session probe skips attach (no phantom binding). @@ -57,6 +109,36 @@ async def main() -> str | None: assert asyncio.run(main()) is None +def test_reconcile_seeds_existing_agent_state_option() -> None: + """A pane option present before subscribe still becomes an agent record.""" + + async def main() -> dict[str, str | None]: + mon = AgentMonitor( + _PaneRowsEngine( + ( + _pane_row( + pane_id="%7", + pane_current_command="claude", + **{"@agent_state": "running", "@agent_name": "claude"}, + ), + ) + ) + ) + await mon.reconcile() + agent = {a.pane_id: a for a in mon.agents}["%7"] + return { + "state": agent.state.value, + "name": agent.name, + "source": agent.source, + } + + assert asyncio.run(main()) == { + "state": "running", + "name": "claude", + "source": "option", + } + + def test_ingest_option_line_updates_agent() -> None: """Option-channel %subscription-changed maps to a store entry.""" mon = AgentMonitor(_FakeEngine()) @@ -65,6 +147,18 @@ def test_ingest_option_line_updates_agent() -> None: assert by_pane["%1"].state is AgentState.RUNNING +def test_ingest_persists_snapshot_immediately() -> None: + """The monitor sink is current while the monitor is still running.""" + sink = _MemorySink() + mon = AgentMonitor(_FakeEngine(), sink=sink) + + mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") + + data = sink.data + assert data is not None + assert AgentStore.from_dict(data).agents["%1"].state is AgentState.RUNNING + + def test_ingest_osc_output_updates_agent() -> None: r"""OSC %output line feeds the OscSignal and lands in the store.""" mon = AgentMonitor(_FakeEngine()) diff --git a/tests/experimental/mcp/test_monitor_live.py b/tests/experimental/mcp/test_monitor_live.py index 9b02a2afb..0b0e55a25 100644 --- a/tests/experimental/mcp/test_monitor_live.py +++ b/tests/experimental/mcp/test_monitor_live.py @@ -15,6 +15,7 @@ import pytest from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.mcp._settle import output_payload fastmcp = pytest.importorskip("fastmcp") @@ -114,8 +115,8 @@ async def produce() -> None: { "target": pane_id, "kinds": ["output"], - "max_events": 1, - "timeout": 10.0, + "max_events": 8, + "timeout": 2.0, }, ) finally: @@ -123,6 +124,11 @@ async def produce() -> None: return result.data data = asyncio.run(main()) - assert data["count"] == 1 - assert data["events"][0]["kind"] == "output" - assert "WATCH_OK" in data["events"][0]["raw"] + assert data["count"] >= 1 + assert {event["kind"] for event in data["events"]} == {"output"} + text = "".join( + payload + for event in data["events"] + if (payload := output_payload(event["raw"], pane_id)) is not None + ) + assert "WATCH_OK" in text From 02b586c7451cdf68c7bd4a06441ed657f2345223 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 06:10:53 -0500 Subject: [PATCH 215/223] Agents(fix[monitor]): Discover agent panes why: The console can start before Claude or Codex lifecycle hooks emit @agent_state, leaving visible agent panes absent from the dashboard. what: - Seed weak process-discovered agent records from pane commands and local descendants - Let explicit option or OSC state override process-discovered records - Mark process-discovered agents exited when the agent command disappears - Add reducer, process-table, and missing-process-table coverage --- src/libtmux/experimental/agents/monitor.py | 54 ++++- src/libtmux/experimental/agents/processes.py | 202 +++++++++++++++++++ tests/experimental/agents/test_monitor.py | 132 ++++++++++++ 3 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 src/libtmux/experimental/agents/processes.py diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index f338cd9e6..7216d7262 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -26,6 +26,7 @@ from libtmux.experimental.agents.health import is_alive from libtmux.experimental.agents.hud import HudRenderer from libtmux.experimental.agents.merge import MonotonicCounter, Stamp +from libtmux.experimental.agents.processes import detect_agent_process from libtmux.experimental.agents.signals import ( SUBSCRIPTION, OptionSignal, @@ -189,7 +190,7 @@ def ingest(self, notification_raw: str) -> None: if opt_reading is not None: self._observe(opt_reading) - def _observe(self, reading: Reading) -> None: + def _observe(self, reading: Reading, *, pid: int | None = None) -> None: """Apply one parsed reading to the store (latest-wins via :func:`apply`). Parameters @@ -209,7 +210,7 @@ def _observe(self, reading: Reading) -> None: # survives across hosts), not merely swapping this Clock implementation. stamp=Stamp(self._clock(), reading.source), source=reading.source, - pid=None, + pid=pid, ) before = self._store.agents.get(reading.pane_id) self._store = apply(self._store, observed, now=time.monotonic()) @@ -592,6 +593,7 @@ async def _reconcile_once(self) -> None: if pane_id != self._hud_pane_id } self._observe_pane_options(current_panes) + self._observe_processes(current_panes) _added, removed = diff_panes(self._prev_panes, current_panes) for pane_id in removed: before = self._store.agents.get(pane_id) @@ -615,12 +617,54 @@ def _observe_pane_options(self, current_panes: dict[str, PaneSnapshot]) -> None: name = pane.fields.get("@agent_name") or None current = self._store.agents.get(pane_id) if current is not None: - if current.source != "option": + if current.source == "osc": continue if current.state is state and current.name == name: continue self._observe(Reading(pane_id, state, name, "option")) + def _observe_processes(self, current_panes: dict[str, PaneSnapshot]) -> None: + """Seed/refresh weak entries from known local agent processes.""" + for pane_id, pane in current_panes.items(): + current = self._store.agents.get(pane_id) + detected = detect_agent_process(pane.current_command, pane.pid) + if detected is None: + if current is not None and current.source == "process": + self._mark_process_exited(current) + continue + if current is not None and current.source != "process": + continue + if ( + current is not None + and current.state is AgentState.RUNNING + and current.name == detected.name + and current.pid == detected.pid + and current.alive + ): + continue + self._observe( + Reading(pane_id, AgentState.RUNNING, detected.name, "process"), + pid=detected.pid, + ) + + def _mark_process_exited(self, agent: Agent) -> None: + """Mark a process-discovered agent as exited when discovery disappears.""" + if agent.state is AgentState.EXITED and not agent.alive: + return + before = agent + after = dataclasses.replace( + agent, + state=AgentState.EXITED, + alive=False, + since=time.monotonic(), + ) + agents = dict(self._store.agents) + agents[agent.pane_id] = after + self._store = AgentStore(agents=agents, stamps=dict(self._store.stamps)) + if self._sink is not None: + self._sink.save(self._store.to_dict()) + self._notify_change(before, after) + def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: """Refresh tracked agents' ``pid``/``alive`` from the pane tree. @@ -652,7 +696,9 @@ def _apply_health(self, current_panes: dict[str, PaneSnapshot]) -> None: pane = current_panes.get(pane_id) if pane is None: continue # not in the tree → Vanished handles it - pid = pane.pid + if agent.source == "process" and agent.state is AgentState.EXITED: + continue # process discovery, not pane shell liveness, revives it + pid = agent.pid if agent.source == "process" and agent.pid else pane.pid if pid is not None and not is_alive(pid): if agent.alive or agent.state is not AgentState.EXITED: exited = dataclasses.replace( diff --git a/src/libtmux/experimental/agents/processes.py b/src/libtmux/experimental/agents/processes.py new file mode 100644 index 000000000..2858d3fd2 --- /dev/null +++ b/src/libtmux/experimental/agents/processes.py @@ -0,0 +1,202 @@ +"""Local process discovery for panes that have not emitted agent state yet.""" + +from __future__ import annotations + +import collections +import pathlib +import typing as t +from dataclasses import dataclass + +_AGENT_COMMANDS = { + "claude": "claude", + "codex": "codex", +} + + +@dataclass(frozen=True) +class ProcessInfo: + """One local process row used for agent discovery. + + Examples + -------- + >>> ProcessInfo(2, 1, "claude", ("claude", "--help")).command + 'claude' + """ + + pid: int + ppid: int + command: str + argv: tuple[str, ...] + + +@dataclass(frozen=True) +class AgentProcess: + """A detected coding-agent process. + + Examples + -------- + >>> AgentProcess("codex", 42).name + 'codex' + """ + + name: str + pid: int | None + + +def agent_name_from_command( + command: str | None, + argv: t.Sequence[str] = (), +) -> str | None: + """Return the known agent name represented by *command* or *argv*. + + Examples + -------- + >>> agent_name_from_command("claude") + 'claude' + >>> agent_name_from_command("node", ("node", "/mise/bin/codex", "--yolo")) + 'codex' + >>> agent_name_from_command("zsh") is None + True + """ + candidates = [command or "", *argv] + for raw in candidates: + name = pathlib.PurePosixPath(raw).name.lower() + if name in _AGENT_COMMANDS: + return _AGENT_COMMANDS[name] + return None + + +def _ppid_from_stat(text: str) -> int | None: + """Parse the parent pid from one Linux ``/proc//stat`` record. + + Examples + -------- + >>> _ppid_from_stat("12 (cmd with ) paren) S 7 8 9") + 7 + >>> _ppid_from_stat("not stat") is None + True + """ + end = text.rfind(")") + if end == -1: + return None + fields = text[end + 2 :].split() + if len(fields) < 2: + return None + try: + return int(fields[1]) + except ValueError: + return None + + +def _cmdline_args(raw: bytes) -> tuple[str, ...]: + r"""Decode a Linux ``cmdline`` byte string to argv tokens. + + Examples + -------- + >>> raw = bytes((99, 111, 100, 101, 120, 0, 45, 45, 121, 111, 108, 111, 0)) + >>> _cmdline_args(raw) + ('codex', '--yolo') + """ + return tuple(part.decode(errors="replace") for part in raw.split(b"\0") if part) + + +def iter_processes( + proc: pathlib.Path = pathlib.Path("/proc"), +) -> t.Iterator[ProcessInfo]: + """Yield local processes visible under *proc*. + + Examples + -------- + >>> import pathlib + >>> import tempfile + >>> with tempfile.TemporaryDirectory() as directory: + ... root = pathlib.Path(directory) + ... process = root / "123" + ... process.mkdir() + ... _ = (process / "stat").write_text("123 (codex) S 1 2 3") + ... _ = (process / "comm").write_text("codex") + ... raw = bytes((99, 111, 100, 101, 120, 0, 45, 45, 121, 111, 108, 111, 0)) + ... _ = (process / "cmdline").write_bytes(raw) + ... [row.command for row in iter_processes(root)] + ['codex'] + """ + try: + items = tuple(proc.iterdir()) + except OSError: + return + for item in items: + if not item.name.isdigit(): + continue + try: + pid = int(item.name) + stat = (item / "stat").read_text(encoding="utf-8", errors="replace") + ppid = _ppid_from_stat(stat) + if ppid is None: + continue + command = (item / "comm").read_text(encoding="utf-8", errors="replace") + argv = _cmdline_args((item / "cmdline").read_bytes()) + except OSError: + continue + yield ProcessInfo(pid, ppid, command.strip(), argv) + + +def _descendants( + root_pid: int, + processes: t.Iterable[ProcessInfo], +) -> t.Iterator[ProcessInfo]: + """Yield descendants of *root_pid* breadth-first. + + Examples + -------- + >>> rows = (ProcessInfo(2, 1, "sh", ()), ProcessInfo(3, 2, "codex", ())) + >>> [row.pid for row in _descendants(1, rows)] + [2, 3] + """ + children: dict[int, list[ProcessInfo]] = {} + for process in processes: + children.setdefault(process.ppid, []).append(process) + seen: set[int] = set() + queue: collections.deque[ProcessInfo] = collections.deque( + children.get(root_pid, ()) + ) + while queue: + process = queue.popleft() + if process.pid in seen: + continue + seen.add(process.pid) + yield process + queue.extend(children.get(process.pid, ())) + + +def detect_agent_process( + current_command: str | None, + root_pid: int | None, + *, + processes: t.Iterable[ProcessInfo] | None = None, +) -> AgentProcess | None: + """Detect a known coding agent for one pane. + + ``current_command`` is the cheap tmux signal. When it is generic, such as + ``node`` for Codex, local ``/proc`` descendants below ``root_pid`` are used. + + Examples + -------- + >>> detect_agent_process("claude", 10) + AgentProcess(name='claude', pid=10) + >>> rows = (ProcessInfo(11, 10, "MainThread", ("node", "/bin/codex")),) + >>> detect_agent_process("node", 10, processes=rows) + AgentProcess(name='codex', pid=11) + >>> detect_agent_process("zsh", 10, processes=()) is None + True + """ + name = agent_name_from_command(current_command) + if name is not None: + return AgentProcess(name, root_pid) + if root_pid is None: + return None + rows = tuple(processes) if processes is not None else tuple(iter_processes()) + for process in _descendants(root_pid, rows): + name = agent_name_from_command(process.command, process.argv) + if name is not None: + return AgentProcess(name, process.pid) + return None diff --git a/tests/experimental/agents/test_monitor.py b/tests/experimental/agents/test_monitor.py index 29882c502..e726f59cd 100644 --- a/tests/experimental/agents/test_monitor.py +++ b/tests/experimental/agents/test_monitor.py @@ -4,8 +4,14 @@ import asyncio import os +import pathlib from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.processes import ( + ProcessInfo, + detect_agent_process, + iter_processes, +) from libtmux.experimental.agents.state import AgentState from libtmux.experimental.agents.store import AgentStore from libtmux.experimental.agents.tree import PANE_FORMAT @@ -139,6 +145,132 @@ async def main() -> dict[str, str | None]: } +def test_reconcile_discovers_agent_process_without_hook() -> None: + """A known agent process appears even before lifecycle hooks emit.""" + + async def main() -> dict[str, str | None]: + mon = AgentMonitor( + _PaneRowsEngine( + ( + _pane_row( + pane_id="%9", + pane_current_command="claude", + **{"@agent_state": "", "@agent_name": ""}, + ), + ) + ) + ) + await mon.reconcile() + agent = {a.pane_id: a for a in mon.agents}["%9"] + return { + "state": agent.state.value, + "name": agent.name, + "source": agent.source, + } + + assert asyncio.run(main()) == { + "state": "running", + "name": "claude", + "source": "process", + } + + +def test_reconcile_option_overrides_process_discovery() -> None: + """Explicit hook state beats the weak process-discovery seed.""" + + async def main() -> dict[str, str | None]: + engine = _PaneRowsEngine( + ( + _pane_row( + pane_id="%9", + pane_current_command="claude", + **{"@agent_state": "", "@agent_name": ""}, + ), + ) + ) + mon = AgentMonitor(engine) + await mon.reconcile() + engine.rows = ( + _pane_row( + pane_id="%9", + pane_current_command="claude", + **{"@agent_state": "awaiting_input", "@agent_name": "claude"}, + ), + ) + await mon.reconcile() + agent = {a.pane_id: a for a in mon.agents}["%9"] + return { + "state": agent.state.value, + "name": agent.name, + "source": agent.source, + } + + assert asyncio.run(main()) == { + "state": "awaiting_input", + "name": "claude", + "source": "option", + } + + +def test_reconcile_exits_process_discovery_when_command_disappears() -> None: + """A process-discovered agent exits when the pane no longer runs an agent.""" + + async def main() -> dict[str, str | bool]: + engine = _PaneRowsEngine( + ( + _pane_row( + pane_id="%9", + pane_current_command="claude", + **{"@agent_state": "", "@agent_name": ""}, + ), + ) + ) + mon = AgentMonitor(engine) + await mon.reconcile() + engine.rows = ( + _pane_row( + pane_id="%9", + pane_current_command="zsh", + **{"@agent_state": "", "@agent_name": ""}, + ), + ) + await mon.reconcile() + agent = {a.pane_id: a for a in mon.agents}["%9"] + return { + "state": agent.state.value, + "source": agent.source, + "alive": agent.alive, + } + + assert asyncio.run(main()) == { + "state": "exited", + "source": "process", + "alive": False, + } + + +def test_detect_agent_process_scans_descendant_argv() -> None: + """Codex launched through node is discovered below the pane shell.""" + detected = detect_agent_process( + "node", + 10, + processes=( + ProcessInfo(10, 1, "zsh", ("/bin/zsh",)), + ProcessInfo(11, 10, "MainThread", ("node", "/mise/bin/codex", "--yolo")), + ProcessInfo(12, 11, "codex", ("/vendor/bin/codex", "--yolo")), + ), + ) + + assert detected is not None + assert detected.name == "codex" + assert detected.pid == 11 + + +def test_iter_processes_tolerates_missing_proc(tmp_path: pathlib.Path) -> None: + """Missing process tables behave like an empty table.""" + assert list(iter_processes(tmp_path / "missing")) == [] + + def test_ingest_option_line_updates_agent() -> None: """Option-channel %subscription-changed maps to a store entry.""" mon = AgentMonitor(_FakeEngine()) From 3eb85cc60b952515edd5d6fcbb3a9763e441d04e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 15:49:42 -0500 Subject: [PATCH 216/223] Rebase(fixup): Reconcile supatui onto the fluent engine-ops why: rebasing the agent-monitor branch onto the fluent engine-ops merged two divergent versions of query.py and the control engines. The mechanical rebase resolution kept engine-ops's split-type pane handles and supatui's fuller async engine; this restores what each side dropped. what: - Re-graft the agents() query (AgentQuery/agents()/ATTENTION/_query_agents) onto the split-type query.py - Re-add tmux_version() to both control engines (kept supatui's engine, which lacked it) - Export workspace_status from the workspace package --- src/libtmux/experimental/query.py | 167 ++++++++++++++++++ .../experimental/workspace/__init__.py | 3 + 2 files changed, 170 insertions(+) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index 0a4ae575a..fbe08a470 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -32,6 +32,7 @@ from dataclasses import dataclass, field from libtmux._internal.query_list import QueryList +from libtmux.experimental.agents.state import AgentState from libtmux.experimental.engines.base import TmuxEngine from libtmux.experimental.ops import ( ClearHistory, @@ -53,13 +54,30 @@ from typing_extensions import Self + from libtmux.experimental.agents.monitor import AgentMonitor + from libtmux.experimental.agents.state import Agent from libtmux.experimental.models.snapshots import PaneSnapshot from libtmux.experimental.ops import Planner, PlanResult from libtmux.experimental.ops._types import SlotRef, Target #: A source of pane snapshots: an engine to read from, or pre-taken snapshots. PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] +#: A source of agent records: a monitor to read its store, or pre-taken records. +AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] MappedT = t.TypeVar("MappedT") +KeyT = t.TypeVar("KeyT") + +#: Default attention ladder for agent rollups (higher value = more urgent). The +#: ordering is a *documented default* a caller can override per call: surveyed +#: orchestrators disagree on the exact weighting, so it is policy, not a rule. +ATTENTION: dict[AgentState, int] = { + AgentState.AWAITING_INPUT: 5, + AgentState.DONE: 4, + AgentState.IDLE: 3, + AgentState.RUNNING: 2, + AgentState.UNKNOWN: 1, + AgentState.EXITED: 0, +} def _snapshot_panes(source: PaneSource) -> tuple[PaneSnapshot, ...]: @@ -353,3 +371,152 @@ def panes() -> PaneQuery: PaneQuery(lookups={'active': True}, order='pane_index', limit_count=1) """ return PaneQuery() + + +def _query_agents(source: AgentSource) -> tuple[Agent, ...]: + """Resolve *source* into agent records (read a monitor's store, or pass through). + + A monitor is detected by its ``agents`` snapshot property (zero tmux calls -- + the store is already populated by the monitor's own drain); any other value + is taken as a pure sequence of :class:`~..agents.state.Agent` records. + """ + store_agents = getattr(source, "agents", None) + if store_agents is not None: + return tuple(store_agents) + return tuple(t.cast("Sequence[Agent]", source)) + + +@dataclass(frozen=True) +class AgentQuery: + """An immutable, chainable query over agents (the agent twin of PaneQuery). + + Resolves against an :data:`AgentSource` -- an + :class:`~..agents.monitor.AgentMonitor` (read straight from its in-process + store, **zero tmux calls**) or a pure sequence of + :class:`~..agents.state.Agent` records. Each method returns a new query; + :meth:`all` / :meth:`first` resolve it. + + Examples + -------- + >>> from libtmux.experimental.agents.state import Agent, AgentState + >>> rows = [ + ... Agent(pane_id="%1", key="%1", name="claude", + ... state=AgentState.AWAITING_INPUT, since=0.0, source="option", + ... pid=None, alive=True), + ... Agent(pane_id="%2", key="%2", name="codex", + ... state=AgentState.RUNNING, since=0.0, source="option", + ... pid=None, alive=True), + ... ] + >>> agents().filter(state=AgentState.AWAITING_INPUT).map( + ... lambda a: a.pane_id).all(rows) + ('%1',) + """ + + lookups: Mapping[str, t.Any] = field(default_factory=dict) + order: str | None = None + limit_count: int | None = None + + def filter(self, **lookups: t.Any) -> AgentQuery: + """Narrow by QueryList lookups (e.g. ``state=AgentState.IDLE``, ``name=``).""" + return dataclasses.replace(self, lookups={**self.lookups, **lookups}) + + def order_by(self, field_name: str) -> AgentQuery: + """Sort the results by an Agent attribute (missing values last).""" + return dataclasses.replace(self, order=field_name) + + def limit(self, count: int) -> AgentQuery: + """Keep only the first *count* results.""" + return dataclasses.replace(self, limit_count=count) + + def all(self, source: AgentSource) -> tuple[Agent, ...]: + """Resolve the query against *source* and return the matched agents.""" + rows: t.Any = QueryList(_query_agents(source)) + if self.lookups: + rows = rows.filter(**self.lookups) + rows = list(rows) + if self.order is not None: + rows.sort(key=lambda agent: _order_key(agent, self.order)) + if self.limit_count is not None: + rows = rows[: self.limit_count] + return tuple(rows) + + def first(self, source: AgentSource) -> Agent | None: + """Return the first matched agent, or ``None`` when none match.""" + rows = self.all(source) + return rows[0] if rows else None + + def map(self, fn: Callable[[Agent], MappedT]) -> MappedAgentQuery[MappedT]: + """Project each matched agent through *fn* (a pure read projection).""" + return MappedAgentQuery(self, fn) + + def most_urgent( + self, + source: AgentSource, + *, + priority: Mapping[AgentState, int] = ATTENTION, + ) -> Agent | None: + """Return the matched agent whose state ranks highest in *priority*. + + The "jump to the agent that needs me" primitive (ties keep input order); + ``None`` when nothing matches. *priority* defaults to :data:`ATTENTION`. + """ + rows = self.all(source) + if not rows: + return None + return max(rows, key=lambda agent: priority.get(agent.state, -1)) + + def rollup( + self, + source: AgentSource, + *, + key: Callable[[Agent], KeyT], + priority: Mapping[AgentState, int] = ATTENTION, + ) -> dict[KeyT, AgentState]: + """Collapse each ``key(agent)`` group to its most-urgent state. + + The fleet "who needs me" read model: group the matched agents by *key* + (e.g. ``lambda a: a.name``) and report, per group, the state with the + highest *priority*. *priority* defaults to :data:`ATTENTION` and is + overridable -- the weighting is policy, not a fixed rule. + """ + best_rank: dict[KeyT, int] = {} + out: dict[KeyT, AgentState] = {} + for agent in self.all(source): + group = key(agent) + rank = priority.get(agent.state, -1) + if group not in best_rank or rank > best_rank[group]: + best_rank[group] = rank + out[group] = agent.state + return out + + +@dataclass(frozen=True) +class MappedAgentQuery(t.Generic[MappedT]): + """An :class:`AgentQuery` whose rows are projected through a function.""" + + query: AgentQuery + fn: Callable[[Agent], MappedT] + + def all(self, source: AgentSource) -> tuple[MappedT, ...]: + """Resolve and project every matched agent.""" + return tuple(self.fn(agent) for agent in self.query.all(source)) + + def first(self, source: AgentSource) -> MappedT | None: + """Resolve and project the first matched agent, or ``None``.""" + first = self.query.first(source) + return self.fn(first) if first is not None else None + + +def agents() -> AgentQuery: + """Start a query over tracked coding agents (the agent twin of :func:`panes`). + + Resolve it against an :class:`~..agents.monitor.AgentMonitor` (zero tmux + calls -- the monitor's store is already live) or a pure sequence of + :class:`~..agents.state.Agent` records. + + Examples + -------- + >>> agents().filter(name="claude").limit(1) + AgentQuery(lookups={'name': 'claude'}, order=None, limit_count=1) + """ + return AgentQuery() diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 4d9d7cceb..50316aeb9 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -61,6 +61,7 @@ build_workspaces, compile_workspaces, ) +from libtmux.experimental.workspace.status import WorkspaceStatus, workspace_status __all__ = ( "BuildEvent", @@ -81,6 +82,7 @@ "WorkspaceCompileError", "WorkspaceSet", "WorkspaceSetResult", + "WorkspaceStatus", "abuild_workspace", "abuild_workspaces", "afreeze_server", @@ -94,4 +96,5 @@ "expand", "freeze", "freeze_server", + "workspace_status", ) From efd351673389d2455d9f18009991fedc872b3c22 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 17:53:45 -0500 Subject: [PATCH 217/223] Agents(refactor[mock]): Follow the MockEngine rename why: The base engine-ops branch renamed the in-memory engine to MockEngine (from the former name); the agent monitor, statusline, and drive examples plus their tests still constructed the old async class. what: - Update the async in-memory engine references to AsyncMockEngine in agents doctests (monitor, statusline, drive) and their tests --- src/libtmux/experimental/agents/drive.py | 8 ++++---- src/libtmux/experimental/agents/monitor.py | 20 +++++++++---------- src/libtmux/experimental/agents/statusline.py | 4 ++-- tests/experimental/agents/test_statusline.py | 4 ++-- tests/experimental/mcp/test_mcp_projection.py | 2 +- tests/experimental/test_query_agents.py | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/libtmux/experimental/agents/drive.py b/src/libtmux/experimental/agents/drive.py index 754331519..1f42aa57a 100644 --- a/src/libtmux/experimental/agents/drive.py +++ b/src/libtmux/experimental/agents/drive.py @@ -186,9 +186,9 @@ async def send_to_agent( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> from libtmux.experimental.agents.monitor import AgentMonitor - >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> mon = AgentMonitor(AsyncMockEngine()) >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") >>> outcome = asyncio.run(send_to_agent(mon, "%1", "echo hi")) >>> outcome.sent @@ -236,9 +236,9 @@ async def send_to_agents( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> from libtmux.experimental.agents.monitor import AgentMonitor - >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> mon = AgentMonitor(AsyncMockEngine()) >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") >>> mon.ingest("%subscription-changed agentstate $0 @0 2 %2 : idle") >>> outs = asyncio.run(send_to_agents(mon, ["%1", "%2"], "echo hi")) diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index 7216d7262..cfe533c0a 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -287,9 +287,9 @@ def waiters(self) -> WaiterRegistry: Examples -------- - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> from libtmux.experimental.agents.wait import WaiterRegistry - >>> isinstance(AgentMonitor(AsyncConcreteEngine()).waiters, WaiterRegistry) + >>> isinstance(AgentMonitor(AsyncMockEngine()).waiters, WaiterRegistry) True """ return self._waiters @@ -300,9 +300,9 @@ def dedup(self) -> DedupLedger: Examples -------- - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> from libtmux.experimental.agents.drive import DedupLedger - >>> isinstance(AgentMonitor(AsyncConcreteEngine()).dedup, DedupLedger) + >>> isinstance(AgentMonitor(AsyncMockEngine()).dedup, DedupLedger) True """ return self._dedup @@ -313,8 +313,8 @@ def engine(self) -> t.Any: Examples -------- - >>> from libtmux.experimental.engines import AsyncConcreteEngine - >>> e = AsyncConcreteEngine() + >>> from libtmux.experimental.engines import AsyncMockEngine + >>> e = AsyncMockEngine() >>> AgentMonitor(e).engine is e True """ @@ -325,8 +325,8 @@ def agent_for(self, pane_id: str) -> Agent | None: Examples -------- - >>> from libtmux.experimental.engines import AsyncConcreteEngine - >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> from libtmux.experimental.engines import AsyncMockEngine + >>> mon = AgentMonitor(AsyncMockEngine()) >>> mon.agent_for("%1") is None True >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") @@ -345,9 +345,9 @@ def add_transition_observer( Examples -------- - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> seen = [] - >>> mon = AgentMonitor(AsyncConcreteEngine()) + >>> mon = AgentMonitor(AsyncMockEngine()) >>> mon.add_transition_observer(seen.append) >>> mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : running") >>> seen[0].after diff --git a/src/libtmux/experimental/agents/statusline.py b/src/libtmux/experimental/agents/statusline.py index acd42fd95..9394be46b 100644 --- a/src/libtmux/experimental/agents/statusline.py +++ b/src/libtmux/experimental/agents/statusline.py @@ -120,12 +120,12 @@ async def paint_status_line( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> from libtmux.experimental.agents.state import Agent, AgentState >>> agents = [Agent(pane_id="%1", key="%1", name=None, ... state=AgentState.AWAITING_INPUT, since=0.0, ... source="option", pid=None, alive=True)] - >>> asyncio.run(paint_status_line(AsyncConcreteEngine(), agents, global_=True)) + >>> asyncio.run(paint_status_line(AsyncMockEngine(), agents, global_=True)) True """ store_agents = getattr(source, "agents", None) diff --git a/tests/experimental/agents/test_statusline.py b/tests/experimental/agents/test_statusline.py index a17a733ec..6c50860ec 100644 --- a/tests/experimental/agents/test_statusline.py +++ b/tests/experimental/agents/test_statusline.py @@ -15,7 +15,7 @@ render_status_line, status_line_op, ) -from libtmux.experimental.engines import AsyncConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops._types import SessionId @@ -92,7 +92,7 @@ def test_paint_reads_store_then_writes_once() -> None: """Paint reads agents from the monitor (0 calls) and writes ONE set-option.""" from libtmux.experimental.agents.monitor import AgentMonitor - mon = AgentMonitor(AsyncConcreteEngine()) + mon = AgentMonitor(AsyncMockEngine()) mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : awaiting_input") rec = _Recorder() ok = asyncio.run(paint_status_line(rec, mon, global_=True)) diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 7376586d5..409e4b421 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -166,7 +166,7 @@ def test_build_workspaces_tool_offline() -> None: {"session_name": "api", "windows": [{"window_name": "w", "panes": ["a"]}]}, {"session_name": "docs", "windows": [{"window_name": "w", "panes": ["b"]}]}, ], - ConcreteEngine(), + MockEngine(), preflight=False, ) diff --git a/tests/experimental/test_query_agents.py b/tests/experimental/test_query_agents.py index 9465cfcb7..022e64b6a 100644 --- a/tests/experimental/test_query_agents.py +++ b/tests/experimental/test_query_agents.py @@ -8,7 +8,7 @@ from __future__ import annotations from libtmux.experimental.agents.state import Agent, AgentState -from libtmux.experimental.engines import AsyncConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine from libtmux.experimental.query import ATTENTION, AgentQuery, agents @@ -53,7 +53,7 @@ def test_query_reads_monitor_store_zero_calls() -> None: """A query resolves against a monitor's live store with no tmux round-trip.""" from libtmux.experimental.agents.monitor import AgentMonitor - mon = AgentMonitor(AsyncConcreteEngine()) + mon = AgentMonitor(AsyncMockEngine()) mon.ingest("%subscription-changed agentstate $0 @0 1 %1 : idle") mon.ingest("%subscription-changed agentstate $0 @0 2 %2 : running") idle = agents().filter(state=AgentState.IDLE).all(mon) From 4c2a801fd691e0f5a466cdd1f85112252458111c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:06:47 -0500 Subject: [PATCH 218/223] Agents(fix[monitor]): Unescape tmux %output bytes why: tmux does not forward pane output verbatim through control mode -- it writes every non-printable byte, and the backslash itself, as a backslash plus three octal digits. An agent's OSC 3008 state escape therefore reached the monitor as the four characters \,0,3,3 rather than ESC. OscSignal's regex matches a raw ESC byte, which real %output never contains, so the remote/SSH agent-state channel never fired against a live tmux server. Every existing test passed because they hand-feed raw ESC bytes that tmux would never deliver. what: - Feed AgentMonitor.ingest's %output payload through engines.base.unescape_control_output (the inbound counterpart to render_control_line) before OscSignal, so the decoder sees the bytes the pane actually wrote. - Add tests/experimental/agents/test_wire_protocol.py: the first test that crosses both ends of the protocol, every payload produced by the real encoder and consumed by the real decoder, including an end-to-end case that drives emit()'s bytes through a real pane pty, tmux, and control mode into the monitor. - Pin four remaining encoder/decoder desyncs as strict xfail: an unescaped ';', BEL, or ESC in a name corrupts the reading, and a name can forge its own state (last-wins parse of an unescaped payload). --- src/libtmux/experimental/agents/monitor.py | 3 +- .../experimental/agents/test_wire_protocol.py | 542 ++++++++++++++++++ 2 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 tests/experimental/agents/test_wire_protocol.py diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index cfe533c0a..a6a390b45 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -43,6 +43,7 @@ ) from libtmux.experimental.agents.tree import PANE_FORMAT, diff_panes, panes_of from libtmux.experimental.agents.wait import WaiterRegistry +from libtmux.experimental.engines.base import unescape_control_output if t.TYPE_CHECKING: from collections.abc import Callable @@ -183,7 +184,7 @@ def ingest(self, notification_raw: str) -> None: if len(parts) < 3: return _tag, pane_id, rest = parts - for reading in self._osc.feed(pane_id, rest.encode()): + for reading in self._osc.feed(pane_id, unescape_control_output(rest)): self._observe(reading) else: opt_reading = OptionSignal.parse(notification_raw) diff --git a/tests/experimental/agents/test_wire_protocol.py b/tests/experimental/agents/test_wire_protocol.py new file mode 100644 index 000000000..49fcae888 --- /dev/null +++ b/tests/experimental/agents/test_wire_protocol.py @@ -0,0 +1,542 @@ +"""The contract test that crosses the agent-state wire protocol's two ends. + +The OSC 3008 agent-state protocol is specified twice, independently: the encoder +lives in :mod:`libtmux.experimental.agents.hooks.emit` and the decoder in +:mod:`libtmux.experimental.agents.signals`, with the option-key literals repeated +again in :mod:`libtmux.experimental.agents.monitor` and +:mod:`libtmux.experimental.agents.tree`. Every other test in the suite exercises +exactly one end against a *hand-written* payload, so the two halves can drift +apart without a single test going red. + +Every test in this module drives the **real** encoder and feeds its **real** +output to the **real** decoder. Nothing here hand-writes a payload; if the magic +number, the delimiters, the option keys, or the terminator ever diverge, these +tests break. + +Cases that are known to be broken *today* are pinned with +``xfail(strict=True)``. They are not "expected behavior": they are the desyncs +this module found, held under glass so that whoever fixes the protocol gets an +``XPASS`` and is forced to retire the marker. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.agents import signals as signals_mod, tree as tree_mod +from libtmux.experimental.agents.hooks.emit import emit +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.signals import OptionSignal, OscSignal, Reading +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.models.snapshots import PaneSnapshot + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.session import Session + +PANE_ID = "%7" + + +# ---------------------------------------------------------------------------- +# Encoder drivers: the only way payloads enter this module is through emit(). +# ---------------------------------------------------------------------------- + + +def encode_osc( + state: str, + name: str | None, + tty: pathlib.Path, +) -> bytes: + """Run the real remote-path encoder and return the bytes it wrote. + + ``emit`` takes the remote branch whenever ``$TMUX`` is absent, writing the + OSC escape to *tty_path* -- here a regular file standing in for the pane pty. + + Parameters + ---------- + state : str + Agent state string handed to the encoder. + name : str or None + Optional agent name handed to the encoder. + tty : pathlib.Path + File that stands in for ``/dev/tty``. + + Returns + ------- + bytes + The exact bytes the encoder put on the wire. + """ + tty.write_bytes(b"") + emit(state, name=name, tty_path=str(tty), env={}) + return tty.read_bytes() + + +def encode_options(state: str, name: str | None) -> dict[str, str]: + """Run the real local-path encoder and return the tmux options it would set. + + ``emit`` takes the local branch when ``$TMUX`` / ``$TMUX_PANE`` are present, + shelling out to ``tmux set-option -p -t ``. The injected + runner captures the argv instead of executing it, so the option **keys** come + from the encoder rather than from this test. + + Parameters + ---------- + state : str + Agent state string handed to the encoder. + name : str or None + Optional agent name handed to the encoder. + + Returns + ------- + dict[str, str] + ``{option_key: option_value}`` as the encoder would write it. + """ + calls: list[list[str]] = [] + emit( + state, + name=name, + runner=lambda argv, **_kwargs: calls.append(argv), + env={"TMUX": "/tmp/tmux-1000/default,1,0", "TMUX_PANE": PANE_ID}, + ) + options: dict[str, str] = {} + for argv in calls: + assert argv[:5] == ["tmux", "set-option", "-p", "-t", PANE_ID] + options[argv[5]] = argv[6] + return options + + +# ---------------------------------------------------------------------------- +# Decoder drivers. +# ---------------------------------------------------------------------------- + + +def decode_osc(raw: bytes, *, chunk: int | None = None) -> list[Reading]: + """Feed *raw* through the real OSC decoder, optionally *chunk* bytes at a time. + + Parameters + ---------- + raw : bytes + Encoder output. + chunk : int or None + Fragment size. ``None`` feeds the buffer in one call. + + Returns + ------- + list[Reading] + Every reading the decoder produced. + """ + osc = OscSignal() + if chunk is None: + return osc.feed(PANE_ID, raw) + readings: list[Reading] = [] + for start in range(0, len(raw), chunk): + readings.extend(osc.feed(PANE_ID, raw[start : start + chunk])) + return readings + + +class _NullEngine: + """The narrowest engine an :class:`AgentMonitor` will accept.""" + + async def run(self, request: t.Any) -> None: + """Accept and drop a request.""" + + async def subscribe(self) -> None: + """Accept and drop a subscribe.""" + + def add_subscription(self, spec: str) -> None: + """Accept and drop a subscription spec.""" + + def set_attach_targets(self, ids: t.Any) -> None: + """Accept and drop attach targets.""" + + +def decode_options(options: dict[str, str]) -> tuple[AgentState, str | None]: + """Feed encoder-written tmux options through the monitor's real option reader. + + This is the local channel end to end: the option keys come from the encoder, + and the reader is :class:`~libtmux.experimental.agents.monitor.AgentMonitor`'s + own reconcile path -- not a copy of it. A key rename on either side lands + here as a missing agent. + + Parameters + ---------- + options : dict[str, str] + ``{option_key: option_value}`` as :func:`encode_options` produced them. + + Returns + ------- + tuple[AgentState, str | None] + The state and name the monitor decoded, or ``(UNKNOWN, None)`` when the + monitor saw no agent at all. + """ + monitor = AgentMonitor(_NullEngine()) + pane = PaneSnapshot.from_format({"pane_id": PANE_ID, **options}) + monitor._observe_pane_options({PANE_ID: pane}) + agents = {agent.pane_id: agent for agent in monitor.agents} + if PANE_ID not in agents: + return AgentState.UNKNOWN, None + return agents[PANE_ID].state, agents[PANE_ID].name + + +def decode_subscription(state_value: str) -> AgentState: + """Decode the ``%subscription-changed`` line tmux emits for *state_value*. + + tmux echoes the option value verbatim in the subscription notification, so + the value threaded through here is the one the encoder actually wrote. + + Parameters + ---------- + state_value : str + The ``@agent_state`` value the encoder wrote. + + Returns + ------- + AgentState + The state :class:`~libtmux.experimental.agents.signals.OptionSignal` + decoded. + """ + line = f"%subscription-changed agentstate $0 @0 1 {PANE_ID} : {state_value}" + reading = OptionSignal.parse(line) + assert reading is not None, "OptionSignal rejected a line tmux would send" + assert reading.pane_id == PANE_ID + return reading.state + + +# ---------------------------------------------------------------------------- +# The state vocabulary survives the round trip -- every member, both channels. +# ---------------------------------------------------------------------------- + + +@pytest.mark.parametrize("state", list(AgentState), ids=lambda s: s.value) +def test_osc_round_trips_every_state( + state: AgentState, + tmp_path: pathlib.Path, +) -> None: + r"""Every ``AgentState`` the encoder emits decodes back to itself over OSC. + + This is the magic-number, delimiter and terminator contract in one assertion: + a decoder that stopped recognizing ``3008``, ``state=``, or ``ESC \`` yields + zero readings here. + """ + raw = encode_osc(state.value, None, tmp_path / "tty") + readings = decode_osc(raw) + assert len(readings) == 1, f"decoder did not recognize encoder output: {raw!r}" + assert readings[0].state is state + assert readings[0].pane_id == PANE_ID + assert readings[0].name is None + assert readings[0].source == "osc" + + +@pytest.mark.parametrize("state", list(AgentState), ids=lambda s: s.value) +def test_options_round_trip_every_state(state: AgentState) -> None: + """Every ``AgentState`` survives the local (tmux option) channel too.""" + options = encode_options(state.value, None) + decoded_state, decoded_name = decode_options(options) + assert decoded_state is state + assert decoded_name is None + assert decode_subscription(options["@agent_state"]) is state + + +# ---------------------------------------------------------------------------- +# Names survive the round trip. +# ---------------------------------------------------------------------------- + + +class NameCase(t.NamedTuple): + """An agent name and the id it is reported under.""" + + test_id: str + name: str + + +BENIGN_NAMES = ( + NameCase("plain", "claude"), + NameCase("with_space", "claude code"), + NameCase("with_dash_and_dot", "gpt-5.4-high"), + NameCase("with_equals", "model=opus"), + NameCase("with_slash", "anthropic/claude"), + NameCase("with_percent_sigil", "%1"), + NameCase("unicode", "клод-über-\U0001f916"), + NameCase("long", "a" * 512), +) + + +@pytest.mark.parametrize("case", BENIGN_NAMES, ids=[c.test_id for c in BENIGN_NAMES]) +def test_osc_round_trips_names(case: NameCase, tmp_path: pathlib.Path) -> None: + """A name the encoder sends comes back byte-identical from the decoder. + + Covers UTF-8 (the encoder ``.encode()``s, the decoder ``.decode()``s) and the + ``=``-in-value case that a naive ``split("=")`` decoder would mangle. + """ + raw = encode_osc("running", case.name, tmp_path / "tty") + readings = decode_osc(raw) + assert len(readings) == 1 + assert readings[0].state is AgentState.RUNNING + assert readings[0].name == case.name + + +@pytest.mark.parametrize("case", BENIGN_NAMES, ids=[c.test_id for c in BENIGN_NAMES]) +def test_options_round_trip_names(case: NameCase) -> None: + """The same name survives the local (tmux option) channel.""" + decoded_state, decoded_name = decode_options(encode_options("running", case.name)) + assert decoded_state is AgentState.RUNNING + assert decoded_name == case.name + + +def test_empty_name_is_absent_on_both_channels(tmp_path: pathlib.Path) -> None: + """``name=""`` means "no name" -- and both ends agree on that.""" + raw = encode_osc("idle", "", tmp_path / "tty") + assert decode_osc(raw)[0].name is None + assert decode_options(encode_options("idle", "")) == (AgentState.IDLE, None) + + +# ---------------------------------------------------------------------------- +# The wire survives tmux's byte-fragmented %output delivery. +# ---------------------------------------------------------------------------- + + +@pytest.mark.parametrize("chunk", [1, 2, 3, 5, 7, 11]) +def test_osc_round_trips_under_fragmentation( + chunk: int, + tmp_path: pathlib.Path, +) -> None: + """Tmux fragments ``%output``; the decoder must reassemble the encoder's bytes.""" + raw = encode_osc("awaiting_input", "claude", tmp_path / "tty") + readings = decode_osc(raw, chunk=chunk) + assert len(readings) == 1 + assert readings[0].state is AgentState.AWAITING_INPUT + assert readings[0].name == "claude" + + +def test_osc_round_trips_at_every_split_point(tmp_path: pathlib.Path) -> None: + r"""Splitting the encoder's bytes anywhere still yields exactly one reading. + + A property check over every possible fragment boundary, including inside the + introducer, inside a multi-byte UTF-8 name, and inside the two-byte ``ESC \`` + terminator -- the three places a hand-picked chunk size would miss. + """ + raw = encode_osc("done", "klüd-\U0001f916", tmp_path / "tty") + for split in range(len(raw) + 1): + osc = OscSignal() + readings = osc.feed(PANE_ID, raw[:split]) + readings.extend(osc.feed(PANE_ID, raw[split:])) + assert len(readings) == 1, f"lost the reading when split at byte {split}" + assert readings[0].state is AgentState.DONE + assert readings[0].name == "klüd-\U0001f916" + + +def test_back_to_back_emissions_all_decode(tmp_path: pathlib.Path) -> None: + """Several emissions coalesced into one ``%output`` chunk all decode, in order.""" + raw = b"".join( + encode_osc(state.value, "claude", tmp_path / "tty") + for state in (AgentState.RUNNING, AgentState.AWAITING_INPUT, AgentState.DONE) + ) + readings = decode_osc(raw) + assert [r.state for r in readings] == [ + AgentState.RUNNING, + AgentState.AWAITING_INPUT, + AgentState.DONE, + ] + assert {r.name for r in readings} == {"claude"} + + +def test_emissions_interleaved_with_pane_noise_decode(tmp_path: pathlib.Path) -> None: + """Ordinary pane output around the escape does not hide it from the decoder.""" + raw = encode_osc("running", "claude", tmp_path / "tty") + noisy = b"$ npm test\r\n\033[1;32mPASS\033[0m\r\n" + raw + b"\r\n$ \033[K" + readings = decode_osc(noisy) + assert len(readings) == 1 + assert readings[0].state is AgentState.RUNNING + assert readings[0].name == "claude" + + +# ---------------------------------------------------------------------------- +# The two channels agree with each other, and the option-key literals agree +# across all four modules that repeat them. +# ---------------------------------------------------------------------------- + + +@pytest.mark.parametrize("state", list(AgentState), ids=lambda s: s.value) +def test_both_channels_decode_identically( + state: AgentState, + tmp_path: pathlib.Path, +) -> None: + """One ``emit`` call, two transports, one answer. + + ``emit`` silently picks the local (option) or the remote (OSC) channel from + ``$TMUX``. A caller cannot control which one runs, so the two must decode to + the same ``(state, name)`` -- otherwise an agent means different things over + SSH than it does at home. + """ + name = "claude code" + osc = decode_osc(encode_osc(state.value, name, tmp_path / "tty"))[0] + option_state, option_name = decode_options(encode_options(state.value, name)) + assert (osc.state, osc.name) == (option_state, option_name) + + +def test_encoder_option_keys_are_the_keys_every_reader_looks_for() -> None: + """The option keys the encoder writes are the ones all three readers read. + + Four modules repeat these literals -- ``emit`` writes them, ``signals`` + subscribes to them, ``tree`` requests them in its pane format, and ``monitor`` + reads them off the snapshot. Renaming ``@agent_state`` in one place and not + the others produces a monitor that observes nothing, silently. The keys here + come from the encoder's own argv, so this test cannot drift with it. + """ + options = encode_options("running", "claude") + state_key, name_key = "@agent_state", "@agent_name" + assert set(options) == {state_key, name_key}, ( + f"encoder changed its option keys: {sorted(options)}" + ) + + # signals.py: the subscription spec must ask tmux for the key emit writes. + assert f"#{{{state_key}}}" in signals_mod.SUBSCRIPTION + + # tree.py: the reconcile pane format must request both keys. + assert state_key in tree_mod.PANE_FORMAT + assert name_key in tree_mod.PANE_FORMAT + + # monitor.py: the reconcile reader must find an agent using only these keys. + assert decode_options(options) == (AgentState.RUNNING, "claude") + + # ...and must find nothing when the keys are absent. + assert decode_options({}) == (AgentState.UNKNOWN, None) + + +def test_osc_magic_number_agrees(tmp_path: pathlib.Path) -> None: + """Encoder and decoder agree on OSC 3008 -- the number, and only that number.""" + raw = encode_osc("running", None, tmp_path / "tty") + assert raw.startswith(b"\033]3008;"), f"encoder changed its introducer: {raw!r}" + assert len(decode_osc(raw)) == 1 + + # A neighbouring OSC number must not be mistaken for ours. + assert decode_osc(raw.replace(b"]3008;", b"]3009;")) == [] + + +# ---------------------------------------------------------------------------- +# KNOWN DESYNCS -- pinned strict-xfail. These are bugs, not behavior. +# ---------------------------------------------------------------------------- + +HOSTILE_NAMES = ( + pytest.param( + "claude;code", + id="semicolon", + marks=pytest.mark.xfail( + strict=True, + reason=( + "DESYNC: emit() writes name= unescaped, but the decoder splits the " + "payload on ';'. A name containing ';' is silently truncated at the " + "first one ('claude;code' decodes as 'claude')." + ), + ), + ), + pytest.param( + "claude\007code", + id="bel", + marks=pytest.mark.xfail( + strict=True, + reason=( + "DESYNC: emit() writes BEL into the payload unescaped, but BEL is an " + "OSC terminator for the decoder. The escape is cut short and the " + "name truncated." + ), + ), + ), + pytest.param( + "claude\033code", + id="esc", + marks=pytest.mark.xfail( + strict=True, + reason=( + "DESYNC: emit() writes ESC into the payload unescaped. The decoder's " + "payload class excludes ESC, so the escape never terminates and the " + "reading is dropped ENTIRELY -- the state is lost, not merely the " + "name." + ), + ), + ), +) + + +@pytest.mark.parametrize("name", HOSTILE_NAMES) +def test_osc_round_trips_hostile_names(name: str, tmp_path: pathlib.Path) -> None: + """A name containing a wire delimiter must still round-trip (it does not). + + The encoder performs no escaping and the decoder performs no unescaping, so + every delimiter in the grammar (``;``, ``=``, ``ESC``, ``BEL``) is a hole. + The fix belongs on the wire format -- percent-encode or base64 the value on + the encoder side and decode it on the reader side, or reject the name up + front -- not in this test. + """ + readings = decode_osc(encode_osc("running", name, tmp_path / "tty")) + assert len(readings) == 1, "the encoder produced bytes the decoder cannot read" + assert readings[0].state is AgentState.RUNNING + assert readings[0].name == name + + +@pytest.mark.xfail( + strict=True, + reason=( + "DESYNC (severity: state corruption): the unescaped name= field can inject " + "a second 'state=' pair into the payload. The decoder's last-wins loop lets " + "an agent NAME overwrite the agent STATE -- emit('running', " + "name='x;state=idle') decodes as IDLE. Any agent that can influence its own " + "name can forge its own state." + ), +) +def test_name_cannot_forge_state(tmp_path: pathlib.Path) -> None: + """The name field must not be able to overwrite the state field.""" + raw = encode_osc("running", "x;state=idle", tmp_path / "tty") + readings = decode_osc(raw) + assert len(readings) == 1 + assert readings[0].state is AgentState.RUNNING, ( + "the name field overwrote the state field" + ) + + +def test_osc_survives_the_real_tmux_output_transport( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """emit()'s bytes, printed in a real pane, reach the monitor as a real state. + + The end-to-end path no unit test covers: encoder -> pane pty -> tmux -> + control-mode ``%output`` -> ``AgentMonitor.ingest`` -> decoder. The escape + bytes are written by the real :func:`emit` into a file which the pane then + ``cat``s, so tmux transports exactly what an agent hook would have emitted + over SSH. + """ + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + payload = tmp_path / "osc.bin" + encode_osc("running", "claude", payload) + + async def main() -> str: + engine = AsyncControlModeEngine.for_server(session.server) + monitor = AgentMonitor(engine) + await monitor.start() + active_pane = session.active_window.active_pane + assert active_pane is not None + pane_id = active_pane.pane_id + assert pane_id is not None + + # The agent hook's effect, for real: the escape bytes hit the pane pty. + session.cmd("send-keys", "-t", pane_id, f"cat {payload}", "Enter") + + seen = "missing" + for _ in range(40): + await asyncio.sleep(0.1) + match = {a.pane_id: a for a in monitor.agents}.get(pane_id) + if match is not None: + seen = match.state.value + if seen == "running": + break + await monitor.stop() + await engine.aclose() + return seen + + assert asyncio.run(main()) == "running" From 93704dbff2a18995ad7c4fdbe53662aa5cab93b4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:21:48 -0500 Subject: [PATCH 219/223] Agents(refactor[protocol]): Own the wire grammar in one codec why: the agent-state protocol was specified twice, independently. The encoder spelled the OSC number, the delimiters and the terminator in hooks/emit.py; the decoder spelled them again in signals.py; and the @agent_state / @agent_name option keys were repeated a third and fourth time in monitor.py and tree.py. Nothing tied the two ends together, so a one-sided edit could not go red. what: - Add agents/protocol.py: one Payload plus encode/decode for each channel, built from a single set of constants. The decoder's regex is compiled from the very literals the encoder writes, and the refresh-client subscription spec is derived from the option name it subscribes to, so there is nowhere left to put a one-sided change. - Reduce hooks/emit.py to a channel choice over the codec, signals.py to buffering policy over decode_osc, and have monitor.py and tree.py take the option keys from the codec rather than restating them. - Keep the decoder fragment-aware: decode_osc returns the unconsumed tail, so buffering policy stays with the caller and the grammar stays in one place. --- src/libtmux/experimental/agents/hooks/emit.py | 33 +- src/libtmux/experimental/agents/monitor.py | 9 +- src/libtmux/experimental/agents/protocol.py | 355 ++++++++++++++++++ src/libtmux/experimental/agents/signals.py | 105 +++--- src/libtmux/experimental/agents/tree.py | 8 +- tests/experimental/agents/test_protocol.py | 220 +++++++++++ 6 files changed, 648 insertions(+), 82 deletions(-) create mode 100644 src/libtmux/experimental/agents/protocol.py create mode 100644 tests/experimental/agents/test_protocol.py diff --git a/src/libtmux/experimental/agents/hooks/emit.py b/src/libtmux/experimental/agents/hooks/emit.py index e4ce0e5d8..8c6f9cc62 100644 --- a/src/libtmux/experimental/agents/hooks/emit.py +++ b/src/libtmux/experimental/agents/hooks/emit.py @@ -1,8 +1,15 @@ """Emit an agent-state signal from inside an agent's lifecycle hook. -Local (tmux reachable): write the ``@agent_state`` pane option. Remote (SSH): -write an ``OSC 3008`` escape to ``/dev/tty`` -- NOT stdout, which agent hooks -pipe/null -- so it reaches the pane pty and travels over SSH into tmux %output. +This is the *write* end of the protocol; it chooses a channel and hands the +payload to the codec. Local (tmux reachable): write the ``@agent_state`` pane +option. Remote (SSH): write an ``OSC 3008`` escape to ``/dev/tty`` -- NOT +stdout, which agent hooks pipe/null -- so it reaches the pane pty and travels +over SSH into tmux ``%output``. + +Neither escape sequence nor option name is spelled here. Both come from +:mod:`libtmux.experimental.agents.protocol`, whose decoders +(:mod:`libtmux.experimental.agents.signals`) read back exactly what these +encoders write. """ from __future__ import annotations @@ -13,6 +20,8 @@ import sys import typing as t +from libtmux.experimental.agents.protocol import Payload, encode_option, encode_osc + if t.TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -51,24 +60,14 @@ def emit( ['tmux', 'set-option'] """ environ = os.environ if env is None else env + payload = Payload(state, name) pane = environ.get("TMUX_PANE") if environ.get("TMUX") and pane: - runner( - ["tmux", "set-option", "-p", "-t", pane, "@agent_state", state], - check=False, - ) - if name: - runner( - ["tmux", "set-option", "-p", "-t", pane, "@agent_name", name], - check=False, - ) + for argv in encode_option(pane, payload): + runner(argv, check=False) return - payload = f"state={state}" - if name: - payload += f";name={name}" - escape = f"\033]3008;{payload}\033\\".encode() with pathlib.Path(tty_path).open("wb", buffering=0) as tty: - tty.write(escape) + tty.write(encode_osc(payload)) def main(argv: Sequence[str] | None = None) -> int: diff --git a/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py index a6a390b45..2a6de25f2 100644 --- a/src/libtmux/experimental/agents/monitor.py +++ b/src/libtmux/experimental/agents/monitor.py @@ -27,6 +27,7 @@ from libtmux.experimental.agents.hud import HudRenderer from libtmux.experimental.agents.merge import MonotonicCounter, Stamp from libtmux.experimental.agents.processes import detect_agent_process +from libtmux.experimental.agents.protocol import decode_option from libtmux.experimental.agents.signals import ( SUBSCRIPTION, OptionSignal, @@ -611,11 +612,11 @@ async def _reconcile_once(self) -> None: def _observe_pane_options(self, current_panes: dict[str, PaneSnapshot]) -> None: """Seed/refresh store entries from durable per-pane option values.""" for pane_id, pane in current_panes.items(): - raw_state = pane.fields.get("@agent_state", "").strip() - if not raw_state: + payload = decode_option(pane.fields) + if payload is None: continue - state = AgentState.from_signal(raw_state) - name = pane.fields.get("@agent_name") or None + state = AgentState.from_signal(payload.state) + name = payload.name current = self._store.agents.get(pane_id) if current is not None: if current.source == "osc": diff --git a/src/libtmux/experimental/agents/protocol.py b/src/libtmux/experimental/agents/protocol.py new file mode 100644 index 000000000..80d24cc01 --- /dev/null +++ b/src/libtmux/experimental/agents/protocol.py @@ -0,0 +1,355 @@ +r"""The agent-state wire protocol: one grammar, both directions. + +An agent reports its state over one of two channels, and this module owns the +*only* definition of each: + +**Option channel** (local; tmux reachable). The agent writes the per-pane user +options ``@agent_state`` / ``@agent_name``; tmux replays them to a subscribed +control client as ``%subscription-changed`` and to ``list-panes`` as format +fields. :func:`encode_option` writes them, :func:`decode_subscription` and +:func:`decode_option` read them back, and :data:`SUBSCRIPTION` -- the +``refresh-client -B`` spec that turns the write into a notification -- is +derived from the same option name. + +**OSC channel** (remote; over SSH). The agent prints a bare ``OSC 3008`` escape +to its pty, which travels verbatim into tmux ``%output``. :func:`encode_osc` +writes it and :func:`decode_osc` reads it back. + +Both channels carry the same payload grammar (:class:`Payload`), and every +literal in it -- the ``3008``, the option names, the ``state=``/``name=`` keys, +the ``ESC \`` / ``BEL`` terminators -- appears exactly once, here. Encoder and +decoder cannot drift apart because there is nothing to drift *from*: they are +two functions over one set of constants. +""" + +from __future__ import annotations + +import re +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import Mapping + +#: The OSC command number carrying an agent-state payload. +OSC_CODE = 3008 + +#: Per-pane tmux user option carrying the agent's state (option channel). +OPTION_STATE = "@agent_state" + +#: Per-pane tmux user option carrying the agent's name (option channel). +OPTION_NAME = "@agent_name" + +#: The tmux user options an agent-aware ``list-panes`` must request. +PANE_OPTIONS: tuple[str, ...] = (OPTION_STATE, OPTION_NAME) + +#: The name of the ``refresh-client -B`` subscription the monitor installs. +SUBSCRIPTION_NAME = "agentstate" + +#: The ``refresh-client -B`` spec the monitor installs for the option channel. +SUBSCRIPTION = f"{SUBSCRIPTION_NAME}:%*:#{{{OPTION_STATE}}}" + +#: Payload key for the agent state, shared by both channels. +KEY_STATE = "state" + +#: Payload key for the agent name, shared by both channels. +KEY_NAME = "name" + +_PAIR_SEP = ";" +_KV_SEP = "=" + +#: OSC introducer: ``ESC ] 3008 ;``. What :func:`encode_osc` writes and +#: :func:`decode_osc` scans for. +_OSC_INTRO = f"\033]{OSC_CODE}{_PAIR_SEP}" +#: String Terminator. The terminator we emit. +_ST = "\033\\" +#: Bell. A terminator we accept but never emit (xterm's legacy OSC ending). +_BEL = "\007" + +#: The escape's grammar, built from the very constants :func:`encode_osc` uses: +#: introducer, a body free of either terminator's lead byte, then a terminator. +_OSC_RE = re.compile( + re.escape(_OSC_INTRO.encode()) + + rb"([^\033\007]*)" + + rb"(?:" + + re.escape(_ST.encode()) + + rb"|" + + re.escape(_BEL.encode()) + + rb")" +) + +_SUB_RE = re.compile( + r"^%subscription-changed\s+" + + re.escape(SUBSCRIPTION_NAME) + + r"\s+\S+\s+\S+\s+\S+\s+(?P%\d+)\s+:\s+(?P\S+)" +) + + +@dataclass(frozen=True) +class Payload: + """What an agent says about itself: a raw state string and optional name. + + This is the wire vocabulary, not the semantic one -- ``state`` is whatever + the agent wrote. Mapping it to an + :class:`~libtmux.experimental.agents.state.AgentState` is the reader's job + (:meth:`~libtmux.experimental.agents.state.AgentState.from_signal`), which + keeps this module a pure grammar and keeps the emitter free of the enum. + + Parameters + ---------- + state : str + Raw agent state string (e.g. ``"running"``). + name : str or None + Optional agent name. + + Examples + -------- + >>> Payload("running") + Payload(state='running', name=None) + >>> Payload("idle", "claude").name + 'claude' + """ + + state: str + name: str | None = None + + +def encode_payload(payload: Payload) -> str: + """Render *payload* as the ``key=value;key=value`` body both channels carry. + + Parameters + ---------- + payload : Payload + The payload to render. + + Returns + ------- + str + The payload body, e.g. ``"state=running;name=claude"``. + + Examples + -------- + >>> encode_payload(Payload("running")) + 'state=running' + >>> encode_payload(Payload("idle", "claude")) + 'state=idle;name=claude' + """ + body = f"{KEY_STATE}{_KV_SEP}{payload.state}" + if payload.name: + body += f"{_PAIR_SEP}{KEY_NAME}{_KV_SEP}{payload.name}" + return body + + +def decode_payload(body: str) -> Payload: + """Parse a ``key=value;key=value`` body back into a :class:`Payload`. + + Unknown keys are ignored and a missing ``state=`` yields an empty state, so + a malformed signal degrades rather than raising. + + Parameters + ---------- + body : str + The payload body, e.g. ``"state=running;name=claude"``. + + Returns + ------- + Payload + The decoded payload. + + Examples + -------- + >>> decode_payload("state=running") + Payload(state='running', name=None) + >>> decode_payload("state=idle;name=claude") + Payload(state='idle', name='claude') + >>> decode_payload("garbage") + Payload(state='', name=None) + """ + state = "" + name: str | None = None + for part in body.split(_PAIR_SEP): + key, _, value = part.partition(_KV_SEP) + if key == KEY_STATE: + state = value + elif key == KEY_NAME: + name = value or None + return Payload(state, name) + + +def encode_osc(payload: Payload) -> bytes: + r"""Render *payload* as an ``OSC 3008`` escape (ST-terminated). + + Printed to a pane's pty this survives SSH and arrives verbatim in tmux + ``%output``, where :func:`decode_osc` reads it back. + + Parameters + ---------- + payload : Payload + The payload to encode. + + Returns + ------- + bytes + The complete escape sequence, ready to write to a tty. + + Examples + -------- + >>> encode_osc(Payload("running")) + b'\x1b]3008;state=running\x1b\\' + >>> decode_osc(encode_osc(Payload("idle", "claude")))[0] + [Payload(state='idle', name='claude')] + """ + return f"{_OSC_INTRO}{encode_payload(payload)}{_ST}".encode() + + +def decode_osc(buffer: bytes) -> tuple[list[Payload], bytes]: + r"""Drain every *complete* ``OSC 3008`` escape out of *buffer*. + + Fragment-aware by construction: tmux delivers ``%output`` byte-fragmented, + so this returns the unconsumed tail alongside the payloads it found. The + caller re-feeds that tail with the next chunk. Buffering *policy* (per-pane + dicts, size caps) stays with the caller; the *grammar* stays here. + + Both terminators tmux may deliver are accepted -- ``ESC \`` (ST) and + ``BEL`` -- even though :func:`encode_osc` only ever writes ST. + + Parameters + ---------- + buffer : bytes + Accumulated bytes, possibly containing partial escapes at either end. + + Returns + ------- + tuple[list[Payload], bytes] + Every complete payload found, in order, plus the unconsumed tail. + + Examples + -------- + A complete escape decodes and leaves nothing behind: + + >>> decode_osc(b"\x1b]3008;state=idle\x1b\\") + ([Payload(state='idle', name=None)], b'') + + A BEL-terminated escape decodes too: + + >>> decode_osc(b"\x1b]3008;state=done\x07") + ([Payload(state='done', name=None)], b'') + + A truncated escape yields nothing and is handed back for the next chunk: + + >>> payloads, tail = decode_osc(b"\x1b]3008;state=run") + >>> payloads, tail + ([], b'\x1b]3008;state=run') + >>> decode_osc(tail + b"ning\x1b\\")[0] + [Payload(state='running', name=None)] + """ + payloads: list[Payload] = [] + while True: + match = _OSC_RE.search(buffer) + if match is None: + break + payloads.append(decode_payload(match.group(1).decode(errors="replace"))) + buffer = buffer[match.end() :] + return payloads, buffer + + +def encode_option(pane_id: str, payload: Payload) -> list[list[str]]: + """Render *payload* as the tmux commands that publish it on *pane_id*. + + One ``set-option`` per populated field. Written this way the option channel + is durable (``list-panes`` can re-read it -- see :func:`decode_option`) and + live (a client subscribed to :data:`SUBSCRIPTION` is notified -- see + :func:`decode_subscription`). + + Parameters + ---------- + pane_id : str + The target pane (e.g. ``"%1"``, or ``$TMUX_PANE``). + payload : Payload + The payload to publish. + + Returns + ------- + list[list[str]] + Zero or more ``tmux`` argv lists, to run in order. + + Examples + -------- + >>> encode_option("%1", Payload("running")) + [['tmux', 'set-option', '-p', '-t', '%1', '@agent_state', 'running']] + + >>> for argv in encode_option("%1", Payload("idle", "claude")): + ... print(argv[5:]) + ['@agent_state', 'idle'] + ['@agent_name', 'claude'] + """ + argvs = [["tmux", "set-option", "-p", "-t", pane_id, OPTION_STATE, payload.state]] + if payload.name: + argvs.append( + ["tmux", "set-option", "-p", "-t", pane_id, OPTION_NAME, payload.name] + ) + return argvs + + +def decode_option(fields: Mapping[str, str]) -> Payload | None: + """Read a payload back out of ``list-panes`` format fields. + + The durable half of the option channel: what :func:`encode_option` wrote is + still readable on reconnect, so a monitor that missed the live notification + can reconcile from a pane listing. + + Parameters + ---------- + fields : Mapping[str, str] + A pane's format fields, as requested via :data:`PANE_OPTIONS`. + + Returns + ------- + Payload or None + The payload, or ``None`` when the pane carries no agent state. + + Examples + -------- + >>> decode_option({"@agent_state": "running", "@agent_name": "claude"}) + Payload(state='running', name='claude') + >>> decode_option({"@agent_state": "", "@agent_name": ""}) is None + True + """ + state = fields.get(OPTION_STATE, "").strip() + if not state: + return None + return Payload(state, fields.get(OPTION_NAME) or None) + + +def decode_subscription(line: str) -> tuple[str, Payload] | None: + """Read a payload out of a ``%subscription-changed`` notification line. + + The live half of the option channel. Non-matching lines yield ``None``, so + this doubles as the classifier for the notification stream. + + Note the asymmetry with :func:`encode_option`: tmux's subscription format + carries one option per notification, so only :data:`OPTION_STATE` arrives + here. The name is filled in from :func:`decode_option` on reconcile. + + Parameters + ---------- + line : str + A raw tmux control-mode notification line. + + Returns + ------- + tuple[str, Payload] or None + The pane id and payload, or ``None`` when *line* is not an agent-state + subscription notification. + + Examples + -------- + >>> decode_subscription("%subscription-changed agentstate $0 @0 1 %3 : running") + ('%3', Payload(state='running', name=None)) + >>> decode_subscription("%output %1 hi") is None + True + """ + match = _SUB_RE.match(line) + if match is None: + return None + return match.group("pane"), Payload(match.group("value")) diff --git a/src/libtmux/experimental/agents/signals.py b/src/libtmux/experimental/agents/signals.py index 2eea91cdd..b6a6f56df 100644 --- a/src/libtmux/experimental/agents/signals.py +++ b/src/libtmux/experimental/agents/signals.py @@ -1,25 +1,40 @@ """The two channels an agent uses to report state. -``OptionSignal`` reads tmux ``@agent_state`` user-options surfaced as -``%subscription-changed`` (local; ~1 s debounced, re-queryable). ``OscSignal`` -reads a bare ``OSC 3008`` escape out of ``%output`` (remote/SSH; instant), with a -per-pane accumulator because tmux delivers ``%output`` byte-fragmented. +This is the *read* end of the protocol; it chooses a channel and hands the +bytes to the codec. ``OptionSignal`` reads tmux ``@agent_state`` user-options +surfaced as ``%subscription-changed`` (local; ~1 s debounced, re-queryable). +``OscSignal`` reads a bare ``OSC 3008`` escape out of ``%output`` (remote/SSH; +instant), with a per-pane accumulator because tmux delivers ``%output`` +byte-fragmented. + +Neither escape sequence nor option name is spelled here. The grammar lives in +:mod:`libtmux.experimental.agents.protocol` alongside the encoders +(:mod:`libtmux.experimental.agents.hooks.emit`) that produce it; what remains +here is *policy*: which channel a line belongs to, the per-pane buffering the +fragmented OSC stream needs, and the mapping from the wire's raw state string +to the :class:`~libtmux.experimental.agents.state.AgentState` vocabulary. """ from __future__ import annotations -import re from dataclasses import dataclass +from libtmux.experimental.agents.protocol import ( + SUBSCRIPTION, + decode_osc, + decode_subscription, +) from libtmux.experimental.agents.state import AgentState -#: The ``refresh-client -B`` spec the monitor installs for the local channel. -SUBSCRIPTION = "agentstate:%*:#{@agent_state}" +__all__ = [ + "SUBSCRIPTION", + "OptionSignal", + "OscSignal", + "Reading", +] -_SUB_RE = re.compile( - r"^%subscription-changed\s+agentstate\s+\S+\s+\S+\s+\S+\s+(?P%\d+)\s+:\s+(?P\S+)" -) -_OSC_RE = re.compile(rb"\033\]3008;([^\033\007]*)(?:\033\\|\007)") +#: Cap the per-pane accumulator so a never-terminated OSC can't grow unbounded. +_MAX_BUFFER = 4096 @dataclass(frozen=True) @@ -51,37 +66,6 @@ class Reading: source: str -def _parse_payload(payload: str) -> tuple[AgentState, str | None]: - """Parse an OSC/option payload like ``state=running`` (``name=`` optional). - - Parameters - ---------- - payload : str - The payload string, e.g., ``"state=running;name=claude"``. - - Returns - ------- - tuple[AgentState, str | None] - The agent state and optional name. - - Examples - -------- - >>> _parse_payload("state=running") - (, None) - >>> _parse_payload("state=idle;name=test") - (, 'test') - """ - state = AgentState.UNKNOWN - name: str | None = None - for part in payload.split(";"): - key, _, value = part.partition("=") - if key == "state": - state = AgentState.from_signal(value) - elif key == "name": - name = value or None - return state, name - - class OptionSignal: """Parse the local ``@agent_state`` subscription channel. @@ -114,11 +98,16 @@ def parse(notification_raw: str) -> Reading | None: >>> OptionSignal.parse("%output %1 hi") is None True """ - match = _SUB_RE.match(notification_raw) - if match is None: + decoded = decode_subscription(notification_raw) + if decoded is None: return None - state = AgentState.from_signal(match.group("value")) - return Reading(match.group("pane"), state, None, "option") + pane_id, payload = decoded + return Reading( + pane_id, + AgentState.from_signal(payload.state), + payload.name, + "option", + ) class OscSignal: @@ -176,16 +165,14 @@ def feed(self, pane_id: str, data: bytes) -> list[Reading]: >>> readings2[0].state.value 'idle' """ - buffer = self._buffers.get(pane_id, b"") + data - readings: list[Reading] = [] - while True: - match = _OSC_RE.search(buffer) - if match is None: - break - payload = match.group(1).decode(errors="replace") - state, name = _parse_payload(payload) - readings.append(Reading(pane_id, state, name, "osc")) - buffer = buffer[match.end() :] - # keep only a bounded tail so a never-terminated OSC can't grow unbounded - self._buffers[pane_id] = buffer[-4096:] - return readings + payloads, tail = decode_osc(self._buffers.get(pane_id, b"") + data) + self._buffers[pane_id] = tail[-_MAX_BUFFER:] + return [ + Reading( + pane_id, + AgentState.from_signal(payload.state), + payload.name, + "osc", + ) + for payload in payloads + ] diff --git a/src/libtmux/experimental/agents/tree.py b/src/libtmux/experimental/agents/tree.py index 8baa4223b..2be1a0d5f 100644 --- a/src/libtmux/experimental/agents/tree.py +++ b/src/libtmux/experimental/agents/tree.py @@ -9,9 +9,14 @@ import typing as t +from libtmux.experimental.agents.protocol import PANE_OPTIONS + if t.TYPE_CHECKING: from libtmux.experimental.models.snapshots import PaneSnapshot, ServerSnapshot +#: The fields the monitor's ``list-panes`` requests. The agent-state options are +#: not spelled here: they come from the protocol module, so what this listing +#: reads back is by construction what the emitter wrote. PANE_FORMAT: tuple[str, ...] = ( "session_id", "session_name", @@ -26,8 +31,7 @@ "pane_pid", "pane_current_command", "pane_title", - "@agent_state", - "@agent_name", + *PANE_OPTIONS, ) diff --git a/tests/experimental/agents/test_protocol.py b/tests/experimental/agents/test_protocol.py new file mode 100644 index 000000000..5f9413f07 --- /dev/null +++ b/tests/experimental/agents/test_protocol.py @@ -0,0 +1,220 @@ +"""Round-trip tests: what the emitter writes is what the signals read back. + +The encoder (``hooks.emit``) and the decoders (``signals``, ``monitor``, +``tree``) used to re-specify the OSC 3008 grammar independently, and no test +crossed the two. These tests cross it: every case drives the *real* emitter and +asserts the *real* decoder recovers the payload -- so a change to one end that +the other doesn't follow fails here. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.agents import protocol +from libtmux.experimental.agents.hooks.emit import emit +from libtmux.experimental.agents.monitor import AgentMonitor +from libtmux.experimental.agents.signals import OptionSignal, OscSignal +from libtmux.experimental.agents.state import AgentState +from libtmux.experimental.agents.tree import PANE_FORMAT + +if t.TYPE_CHECKING: + import pathlib + + +class SignalCase(t.NamedTuple): + """One agent signal, as the emitter's CLI-level arguments.""" + + test_id: str + state: str + name: str | None + expected_state: AgentState + + +SIGNAL_CASES = ( + SignalCase("running", "running", None, AgentState.RUNNING), + SignalCase("idle_named", "idle", "claude", AgentState.IDLE), + SignalCase("awaiting_named", "awaiting_input", "codex", AgentState.AWAITING_INPUT), + SignalCase("done", "done", None, AgentState.DONE), + SignalCase("exited_named", "exited", "aider", AgentState.EXITED), + SignalCase("garbage_is_unknown", "nonsense", None, AgentState.UNKNOWN), +) + +IDS = [c.test_id for c in SIGNAL_CASES] + + +class _FakeEngine: + """The minimum surface :class:`AgentMonitor` touches when only ingesting.""" + + async def run(self, request: object) -> None: + """Never called: these tests only drive the synchronous reducer.""" + + async def subscribe(self) -> None: + """Never called: these tests only drive the synchronous reducer.""" + + def add_subscription(self, spec: str) -> None: + """Record nothing; the monitor installs this on ``start()`` only.""" + + def set_attach_targets(self, ids: list[str]) -> None: + """Record nothing; the monitor attaches on ``start()`` only.""" + + +def _emit_osc(case: SignalCase, tty: pathlib.Path) -> bytes: + """Drive the real remote-path emitter and return the bytes it wrote.""" + tty.write_bytes(b"") + emit(case.state, name=case.name, tty_path=str(tty), env={}) + return tty.read_bytes() + + +def _emit_option(case: SignalCase, pane_id: str) -> list[list[str]]: + """Drive the real local-path emitter and return the tmux argv it ran.""" + calls: list[list[str]] = [] + emit( + case.state, + name=case.name, + runner=lambda argv, **kw: calls.append(argv), + env={"TMUX": "/tmp/sock,1,0", "TMUX_PANE": pane_id}, + ) + return calls + + +@pytest.mark.parametrize("case", SIGNAL_CASES, ids=IDS) +def test_osc_round_trip(case: SignalCase, tmp_path: pathlib.Path) -> None: + """emit() → OSC bytes → OscSignal recovers the same state and name.""" + data = _emit_osc(case, tmp_path / "tty") + + readings = OscSignal().feed("%7", data) + + assert len(readings) == 1 + assert readings[0].pane_id == "%7" + assert readings[0].state is case.expected_state + assert readings[0].name == case.name + assert readings[0].source == "osc" + + +@pytest.mark.parametrize("case", SIGNAL_CASES, ids=IDS) +def test_osc_round_trip_byte_fragmented( + case: SignalCase, + tmp_path: pathlib.Path, +) -> None: + """The round trip survives tmux's byte-fragmented ``%output`` delivery.""" + data = _emit_osc(case, tmp_path / "tty") + + osc = OscSignal() + readings = [r for i in range(len(data)) for r in osc.feed("%2", data[i : i + 1])] + + assert len(readings) == 1 + assert readings[0].state is case.expected_state + assert readings[0].name == case.name + + +@pytest.mark.parametrize("case", SIGNAL_CASES, ids=IDS) +def test_osc_round_trip_through_monitor( + case: SignalCase, + tmp_path: pathlib.Path, +) -> None: + """emit() → OSC bytes → ``%output`` notification → the monitor's agent tree.""" + data = _emit_osc(case, tmp_path / "tty") + + mon = AgentMonitor(_FakeEngine()) + mon.ingest(f"%output %5 {data.decode()}") + + (agent,) = mon.agents + assert agent.pane_id == "%5" + assert agent.state is case.expected_state + assert agent.name == case.name + + +@pytest.mark.parametrize("case", SIGNAL_CASES, ids=IDS) +def test_option_round_trip_via_reconcile(case: SignalCase) -> None: + """emit() → tmux set-option → the durable fields the monitor reconciles from. + + The reconcile path re-reads the options with ``list-panes``; model that by + replaying the emitted ``set-option`` argv into a field mapping and decoding + it with the same reader the monitor uses. + """ + fields = dict.fromkeys(protocol.PANE_OPTIONS, "") + for argv in _emit_option(case, "%1"): + key, value = argv[-2], argv[-1] + fields[key] = value + + payload = protocol.decode_option(fields) + + assert payload is not None + assert AgentState.from_signal(payload.state) is case.expected_state + assert payload.name == case.name + + +@pytest.mark.parametrize("case", SIGNAL_CASES, ids=IDS) +def test_option_round_trip_via_subscription(case: SignalCase) -> None: + """emit() → tmux set-option → the ``%subscription-changed`` line it triggers. + + tmux echoes the *value* of the subscribed option back on the notification + stream. Build that line from the protocol's own subscription name and the + value the emitter actually set -- no literal from either end is retyped. + """ + argv = _emit_option(case, "%3")[0] + assert argv[-2] == protocol.OPTION_STATE + value = argv[-1] + + line = f"%subscription-changed {protocol.SUBSCRIPTION_NAME} $0 @0 1 %3 : {value}" + reading = OptionSignal.parse(line) + + assert reading is not None + assert reading.pane_id == "%3" + assert reading.state is case.expected_state + assert reading.source == "option" + + +def test_subscription_spec_watches_the_option_the_emitter_writes() -> None: + """The option the emitter sets is the one the subscription spec asks tmux for. + + This is the desync the codec exists to prevent: renaming the option on the + write side while the ``refresh-client -B`` spec still watches the old name + would silence the local channel with no other test noticing. + """ + argv = _emit_option(SIGNAL_CASES[0], "%1")[0] + written_option = argv[-2] + + assert f"#{{{written_option}}}" in protocol.SUBSCRIPTION + assert written_option in PANE_FORMAT + + +def test_osc_round_trip_is_pure_at_the_codec_level() -> None: + """decode_osc(encode_osc(p)) == p for every payload the emitter can build.""" + for case in SIGNAL_CASES: + payload = protocol.Payload(case.state, case.name) + decoded, tail = protocol.decode_osc(protocol.encode_osc(payload)) + assert decoded == [payload] + assert tail == b"" + + +def test_decode_osc_hands_back_a_partial_escape() -> None: + """A truncated escape yields no payload and is returned for the next chunk.""" + complete = protocol.encode_osc(protocol.Payload("running")) + head, rest = complete[:10], complete[10:] + + payloads, tail = protocol.decode_osc(head) + assert payloads == [] + assert tail == head + + payloads, tail = protocol.decode_osc(tail + rest) + assert payloads == [protocol.Payload("running")] + assert tail == b"" + + +def test_decode_osc_drains_several_escapes_from_one_chunk() -> None: + """Back-to-back escapes in one ``%output`` frame all decode, in order.""" + chunk = protocol.encode_osc(protocol.Payload("running")) + protocol.encode_osc( + protocol.Payload("idle", "claude") + ) + + payloads, tail = protocol.decode_osc(b"noise" + chunk + b"trailing") + + assert payloads == [ + protocol.Payload("running"), + protocol.Payload("idle", "claude"), + ] + assert tail == b"trailing" From f0a750d8972d8803934ec1e4580d97c1456923ad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:22:10 -0500 Subject: [PATCH 220/223] Agents(fix[protocol]): Escape payload values on the wire why: the OSC payload is a ';'-delimited, '='-paired body and the decoder takes the last value for a repeated key, but values went onto the wire raw. An agent name is attacker-influenced, so a name could inject a second state= pair and overwrite the state it was reported with: emit(state="running", name="x;state=idle") decoded as IDLE. Any agent able to influence its own name could forge its own state. The same hole truncated a name at a ';', and a name carrying BEL or ESC -- both OSC terminators -- cut the escape short or dropped the reading entirely, losing the state and not merely the name. what: - Percent-encode payload values in encode_payload and decode them in decode_payload, so no character of a value can be structural. Benign values are unreserved, so the common wire form is unchanged and a payload written by an older agent still reads. - Escape only the OSC payload: the option channel carries its values as argv and format fields, which have no delimiter to smuggle, so both channels still decode a name identically. - Retire the four xfails this closes and cover two more hostile names: a '=' in a name, and a name that merely looks like a percent-escape (it must not be double-decoded). --- src/libtmux/experimental/agents/protocol.py | 45 +++++++++-- .../experimental/agents/test_wire_protocol.py | 75 +++++-------------- 2 files changed, 59 insertions(+), 61 deletions(-) diff --git a/src/libtmux/experimental/agents/protocol.py b/src/libtmux/experimental/agents/protocol.py index 80d24cc01..4bb919ddc 100644 --- a/src/libtmux/experimental/agents/protocol.py +++ b/src/libtmux/experimental/agents/protocol.py @@ -26,6 +26,7 @@ import re import typing as t +import urllib.parse from dataclasses import dataclass if t.TYPE_CHECKING: @@ -114,9 +115,29 @@ class Payload: name: str | None = None +def _quote(value: str) -> str: + """Percent-encode a payload value so no character of it can be structural. + + ``safe=""`` leaves only the unreserved ASCII set intact, so the pair + separator, the key/value separator, the percent sign itself, and both OSC + terminators (``ESC`` and ``BEL``) all come out as ``%XX``. + """ + return urllib.parse.quote(value, safe="") + + def encode_payload(payload: Payload) -> str: """Render *payload* as the ``key=value;key=value`` body both channels carry. + Values are percent-encoded, so a value can never be mistaken for structure. + This is what stops an agent's *name* from forging its own *state*: the name + is attacker-influenced, the body is delimited by ``;`` and ``=``, and the + decoder takes the last value for a repeated key -- so an unescaped + ``name=x;state=idle`` would overwrite the state. It also keeps a name + containing ``ESC`` or ``BEL`` from terminating the OSC escape early and + truncating the reading. + + Benign values are unreserved, so the common wire form is unchanged. + Parameters ---------- payload : Payload @@ -133,18 +154,25 @@ def encode_payload(payload: Payload) -> str: 'state=running' >>> encode_payload(Payload("idle", "claude")) 'state=idle;name=claude' + + A name that tries to smuggle structure is neutralized: + + >>> encode_payload(Payload("running", "x;state=idle")) + 'state=running;name=x%3Bstate%3Didle' """ - body = f"{KEY_STATE}{_KV_SEP}{payload.state}" + body = f"{KEY_STATE}{_KV_SEP}{_quote(payload.state)}" if payload.name: - body += f"{_PAIR_SEP}{KEY_NAME}{_KV_SEP}{payload.name}" + body += f"{_PAIR_SEP}{KEY_NAME}{_KV_SEP}{_quote(payload.name)}" return body def decode_payload(body: str) -> Payload: """Parse a ``key=value;key=value`` body back into a :class:`Payload`. - Unknown keys are ignored and a missing ``state=`` yields an empty state, so - a malformed signal degrades rather than raising. + Values are percent-decoded, reversing :func:`encode_payload`. Unknown keys + are ignored and a missing ``state=`` yields an empty state, so a malformed + signal degrades rather than raising. A value with no escapes decodes to + itself, so a payload written by an older agent still reads. Parameters ---------- @@ -164,15 +192,20 @@ def decode_payload(body: str) -> Payload: Payload(state='idle', name='claude') >>> decode_payload("garbage") Payload(state='', name=None) + + The escaped name round-trips, and the state it tried to forge does not win: + + >>> decode_payload("state=running;name=x%3Bstate%3Didle") + Payload(state='running', name='x;state=idle') """ state = "" name: str | None = None for part in body.split(_PAIR_SEP): key, _, value = part.partition(_KV_SEP) if key == KEY_STATE: - state = value + state = urllib.parse.unquote(value) elif key == KEY_NAME: - name = value or None + name = urllib.parse.unquote(value) or None return Payload(state, name) diff --git a/tests/experimental/agents/test_wire_protocol.py b/tests/experimental/agents/test_wire_protocol.py index 49fcae888..d9c5a647b 100644 --- a/tests/experimental/agents/test_wire_protocol.py +++ b/tests/experimental/agents/test_wire_protocol.py @@ -418,59 +418,27 @@ def test_osc_magic_number_agrees(tmp_path: pathlib.Path) -> None: # ---------------------------------------------------------------------------- -# KNOWN DESYNCS -- pinned strict-xfail. These are bugs, not behavior. +# HOSTILE NAMES -- every delimiter of the grammar, carried inside a value. # ---------------------------------------------------------------------------- HOSTILE_NAMES = ( - pytest.param( - "claude;code", - id="semicolon", - marks=pytest.mark.xfail( - strict=True, - reason=( - "DESYNC: emit() writes name= unescaped, but the decoder splits the " - "payload on ';'. A name containing ';' is silently truncated at the " - "first one ('claude;code' decodes as 'claude')." - ), - ), - ), - pytest.param( - "claude\007code", - id="bel", - marks=pytest.mark.xfail( - strict=True, - reason=( - "DESYNC: emit() writes BEL into the payload unescaped, but BEL is an " - "OSC terminator for the decoder. The escape is cut short and the " - "name truncated." - ), - ), - ), - pytest.param( - "claude\033code", - id="esc", - marks=pytest.mark.xfail( - strict=True, - reason=( - "DESYNC: emit() writes ESC into the payload unescaped. The decoder's " - "payload class excludes ESC, so the escape never terminates and the " - "reading is dropped ENTIRELY -- the state is lost, not merely the " - "name." - ), - ), - ), + pytest.param("claude;code", id="semicolon"), + pytest.param("claude\007code", id="bel"), + pytest.param("claude\033code", id="esc"), + pytest.param("claude=code", id="equals"), + pytest.param("claude%3Bcode", id="literal_percent_escape"), ) @pytest.mark.parametrize("name", HOSTILE_NAMES) def test_osc_round_trips_hostile_names(name: str, tmp_path: pathlib.Path) -> None: - """A name containing a wire delimiter must still round-trip (it does not). + """A name carrying a wire delimiter round-trips instead of corrupting the read. - The encoder performs no escaping and the decoder performs no unescaping, so - every delimiter in the grammar (``;``, ``=``, ``ESC``, ``BEL``) is a hole. - The fix belongs on the wire format -- percent-encode or base64 the value on - the encoder side and decode it on the reader side, or reject the name up - front -- not in this test. + Every delimiter of the grammar (``;``, ``=``, ``ESC``, ``BEL``) used to be a + hole: ``;`` truncated the name, ``BEL`` cut the escape short, and ``ESC`` + dropped the reading entirely because the payload class excludes it. The + values are percent-encoded on the wire now, so none of them can be + structural. """ readings = decode_osc(encode_osc("running", name, tmp_path / "tty")) assert len(readings) == 1, "the encoder produced bytes the decoder cannot read" @@ -478,24 +446,21 @@ def test_osc_round_trips_hostile_names(name: str, tmp_path: pathlib.Path) -> Non assert readings[0].name == name -@pytest.mark.xfail( - strict=True, - reason=( - "DESYNC (severity: state corruption): the unescaped name= field can inject " - "a second 'state=' pair into the payload. The decoder's last-wins loop lets " - "an agent NAME overwrite the agent STATE -- emit('running', " - "name='x;state=idle') decodes as IDLE. Any agent that can influence its own " - "name can forge its own state." - ), -) def test_name_cannot_forge_state(tmp_path: pathlib.Path) -> None: - """The name field must not be able to overwrite the state field.""" + """The name field must not be able to overwrite the state field. + + The payload is a ``;``-delimited, ``=``-paired body and the decoder takes the + last value for a repeated key, so an unescaped name could inject a second + ``state=`` pair: any agent able to influence its own name could forge its own + state. Percent-encoding the value closes it. + """ raw = encode_osc("running", "x;state=idle", tmp_path / "tty") readings = decode_osc(raw) assert len(readings) == 1 assert readings[0].state is AgentState.RUNNING, ( "the name field overwrote the state field" ) + assert readings[0].name == "x;state=idle" def test_osc_survives_the_real_tmux_output_transport( From d3308a6924c1254fbb2ce4ac48b3a0b1aa93fe4a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:50:42 -0500 Subject: [PATCH 221/223] Agents(refactor[dedup]): Import the helper, don't re-inline it why: The temp-file + fsync + os.replace crash-safe writer was typed out three times (the store, the Claude hook, the Codex hook) -- three chances to forget the fsync or leak the temp file on error. The "resolve an agent source" reader and the attention ladder were split across query.py, workspace/status.py and agents/statusline.py, each with its own copy of the AgentSource union. what: - Add agents/_atomic.py `atomic_write_text`; the store and both hook installers write through it - Add agents/source.py: the AgentSource union, `agent_rows`, ATTENTION and the ATTENTION_ORDER derived from it -- one leaf module the query layer, the workspace status and the statusline all read - query.py re-exports AgentSource/ATTENTION, so its public surface is unchanged --- src/libtmux/experimental/agents/_atomic.py | 53 +++++++++++++ .../experimental/agents/hooks/claude.py | 19 +---- .../experimental/agents/hooks/codex.py | 21 +----- src/libtmux/experimental/agents/source.py | 74 +++++++++++++++++++ src/libtmux/experimental/agents/statusline.py | 26 +------ src/libtmux/experimental/agents/store.py | 18 +---- src/libtmux/experimental/query.py | 35 ++------- src/libtmux/experimental/workspace/status.py | 17 +---- 8 files changed, 147 insertions(+), 116 deletions(-) create mode 100644 src/libtmux/experimental/agents/_atomic.py create mode 100644 src/libtmux/experimental/agents/source.py diff --git a/src/libtmux/experimental/agents/_atomic.py b/src/libtmux/experimental/agents/_atomic.py new file mode 100644 index 000000000..a74576ba3 --- /dev/null +++ b/src/libtmux/experimental/agents/_atomic.py @@ -0,0 +1,53 @@ +"""Crash-safe file writes for the agent store and the hook installers. + +Everything the agents layer persists -- the state store, an agent's settings JSON, +an agent's config TOML -- is a file another process (the agent itself) reads +concurrently. A partial write would hand that reader a truncated document, so +every write goes through :func:`atomic_write_text`: a sibling temp file, an +``fsync``, and an ``os.replace`` (atomic within a filesystem). +""" + +from __future__ import annotations + +import contextlib +import os +import pathlib +import tempfile + + +def atomic_write_text(path: pathlib.Path, content: str) -> None: + """Write *content* to *path* atomically, creating parent directories. + + No partial file ever survives a crash: the bytes land in a sibling temp file + that is flushed and ``fsync``'d, then renamed over *path* in one step. The + temp file is removed if anything raises. + + Parameters + ---------- + path : pathlib.Path + Destination file. + content : str + Full UTF-8 text to write. + + Examples + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... target = pathlib.Path(d) / "nested" / "state.json" + ... atomic_write_text(target, '{"agents": {}}') + ... target.read_text() + '{"agents": {}}' + """ + directory = path.parent + directory.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(directory), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + pathlib.Path(tmp).replace(path) + except BaseException: + with contextlib.suppress(OSError): + pathlib.Path(tmp).unlink() + raise diff --git a/src/libtmux/experimental/agents/hooks/claude.py b/src/libtmux/experimental/agents/hooks/claude.py index 3681077cc..219bf3c9d 100644 --- a/src/libtmux/experimental/agents/hooks/claude.py +++ b/src/libtmux/experimental/agents/hooks/claude.py @@ -15,14 +15,13 @@ from __future__ import annotations -import contextlib import json -import os import pathlib import shutil -import tempfile import typing as t +from libtmux.experimental.agents._atomic import atomic_write_text + #: Claude Code event name → agent-state string. #: #: Maps each Claude lifecycle event to the agent-state value that @@ -201,19 +200,7 @@ def _save(self, data: dict[str, t.Any]) -> None: >>> result {'hooks': {}} """ - directory = self._settings_path.parent - directory.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=str(directory), suffix=".tmp") - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - json.dump(data, fh, indent=2) - fh.flush() - os.fsync(fh.fileno()) - pathlib.Path(tmp).replace(self._settings_path) - except BaseException: - with contextlib.suppress(OSError): - pathlib.Path(tmp).unlink() - raise + atomic_write_text(self._settings_path, json.dumps(data, indent=2)) # ------------------------------------------------------------------ # Public interface (AgentHook protocol) diff --git a/src/libtmux/experimental/agents/hooks/codex.py b/src/libtmux/experimental/agents/hooks/codex.py index ab7a7f3de..478a98874 100644 --- a/src/libtmux/experimental/agents/hooks/codex.py +++ b/src/libtmux/experimental/agents/hooks/codex.py @@ -38,12 +38,11 @@ from __future__ import annotations -import contextlib -import os import pathlib import re import shutil -import tempfile + +from libtmux.experimental.agents._atomic import atomic_write_text #: Codex event name → agent-state string. #: @@ -181,21 +180,7 @@ def _write(self, content: str) -> None: >>> text 'model = "o4"\n' """ - self._config_path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_path = tempfile.mkstemp( - dir=str(self._config_path.parent), - suffix=".tmp", - ) - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(content) - fh.flush() - os.fsync(fh.fileno()) - pathlib.Path(tmp_path).replace(self._config_path) - except BaseException: - with contextlib.suppress(OSError): - pathlib.Path(tmp_path).unlink() - raise + atomic_write_text(self._config_path, content) def _build_block(self) -> str: r"""Build the marker-bounded TOML block string. diff --git a/src/libtmux/experimental/agents/source.py b/src/libtmux/experimental/agents/source.py new file mode 100644 index 000000000..11d19b3ca --- /dev/null +++ b/src/libtmux/experimental/agents/source.py @@ -0,0 +1,74 @@ +"""Where agent records come from, and how urgent they are. + +Every read-side surface over the fleet -- the :mod:`~libtmux.experimental.query` +agent query, the workspace status projection, the status-line painter -- takes +the same polymorphic source (a live :class:`~.monitor.AgentMonitor`, or a plain +sequence of :class:`~.state.Agent` records) and ranks the result by the same +attention ladder. Both live here, in a leaf module every one of those surfaces +can import. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.agents.state import AgentState + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.agents.monitor import AgentMonitor + from libtmux.experimental.agents.state import Agent + +#: A source of agent records: a monitor to read its store, or pre-taken records. +AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] + +#: Default attention ladder for agent rollups (higher value = more urgent). The +#: ordering is a *documented default* a caller can override per call: surveyed +#: orchestrators disagree on the exact weighting, so it is policy, not a rule. +ATTENTION: dict[AgentState, int] = { + AgentState.AWAITING_INPUT: 5, + AgentState.DONE: 4, + AgentState.IDLE: 3, + AgentState.RUNNING: 2, + AgentState.UNKNOWN: 1, + AgentState.EXITED: 0, +} + +#: The states in attention order, most urgent first (derived from :data:`ATTENTION` +#: so a re-weighting cannot leave a renderer's order stale). +ATTENTION_ORDER: tuple[AgentState, ...] = tuple( + sorted(ATTENTION, key=lambda state: ATTENTION[state], reverse=True), +) + + +def agent_rows(source: AgentSource) -> tuple[Agent, ...]: + """Resolve *source* into agent records (read a monitor's store, or pass through). + + A monitor is detected by its ``agents`` snapshot property (zero tmux calls -- + the store is already populated by the monitor's own drain); any other value is + taken as a pure sequence of :class:`~.state.Agent` records. + + Parameters + ---------- + source : AgentSource + A monitor, or a sequence of agent records. + + Returns + ------- + tuple[Agent, ...] + + Examples + -------- + >>> from libtmux.experimental.agents.state import Agent, AgentState + >>> record = Agent(pane_id="%1", key="%1", name=None, state=AgentState.RUNNING, + ... since=0.0, source="option", pid=None, alive=True) + >>> agent_rows([record]) == (record,) + True + >>> agent_rows(()) + () + """ + store_agents = getattr(source, "agents", None) + if store_agents is not None: + return tuple(store_agents) + return tuple(t.cast("Sequence[Agent]", source)) diff --git a/src/libtmux/experimental/agents/statusline.py b/src/libtmux/experimental/agents/statusline.py index 9394be46b..4e49eb172 100644 --- a/src/libtmux/experimental/agents/statusline.py +++ b/src/libtmux/experimental/agents/statusline.py @@ -17,13 +17,13 @@ import collections import typing as t +from libtmux.experimental.agents.source import ATTENTION_ORDER, AgentSource, agent_rows from libtmux.experimental.agents.state import AgentState from libtmux.experimental.ops import SetOption, arun if t.TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence - from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import Agent from libtmux.experimental.engines.base import AsyncTmuxEngine from libtmux.experimental.ops._types import Target @@ -38,20 +38,6 @@ AgentState.UNKNOWN: "?", } -#: The order states appear in the default tally (most-urgent first). -_ATTENTION_ORDER: tuple[AgentState, ...] = ( - AgentState.AWAITING_INPUT, - AgentState.DONE, - AgentState.IDLE, - AgentState.RUNNING, - AgentState.UNKNOWN, - AgentState.EXITED, -) - -#: A source of agent records: a monitor (read its ``agents`` snapshot at zero -#: tmux cost), or a pure sequence of :class:`~..state.Agent`. -AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] - def render_status_line( agents: Sequence[Agent], @@ -79,7 +65,7 @@ def render_status_line( counts = collections.Counter(agent.state for agent in agents) parts = [ f"{merged[state]}:{counts[state]}" - for state in _ATTENTION_ORDER + for state in ATTENTION_ORDER if counts.get(state) ] return " ".join(parts) @@ -128,13 +114,7 @@ async def paint_status_line( >>> asyncio.run(paint_status_line(AsyncMockEngine(), agents, global_=True)) True """ - store_agents = getattr(source, "agents", None) - agents: Sequence[Agent] = ( - store_agents - if store_agents is not None - else tuple(t.cast("Sequence[Agent]", source)) - ) - value = render(agents) + value = render(agent_rows(source)) op = status_line_op(value, option=option, target=target, global_=global_) result = await arun(op, engine) return result.ok diff --git a/src/libtmux/experimental/agents/store.py b/src/libtmux/experimental/agents/store.py index 992a4e3e8..e2a8fa2d1 100644 --- a/src/libtmux/experimental/agents/store.py +++ b/src/libtmux/experimental/agents/store.py @@ -8,15 +8,13 @@ from __future__ import annotations -import contextlib import dataclasses import json -import os import pathlib -import tempfile import typing as t from dataclasses import dataclass, field +from libtmux.experimental.agents._atomic import atomic_write_text from libtmux.experimental.agents.merge import Stamp, latest from libtmux.experimental.agents.state import Agent, AgentState @@ -330,16 +328,4 @@ def save(self, data: dict[str, t.Any]) -> None: >>> sink.load() {'agents': {}, 'stamps': {}} """ - directory = self._path.parent - directory.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=str(directory), suffix=".tmp") - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(data, handle) - handle.flush() - os.fsync(handle.fileno()) - pathlib.Path(tmp).replace(self._path) - except BaseException: - with contextlib.suppress(OSError): - pathlib.Path(tmp).unlink() - raise + atomic_write_text(self._path, json.dumps(data)) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index fbe08a470..1eee69cfe 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -32,6 +32,11 @@ from dataclasses import dataclass, field from libtmux._internal.query_list import QueryList +from libtmux.experimental.agents.source import ( + ATTENTION as ATTENTION, # re-exported: the ladder's public home is the query layer + AgentSource as AgentSource, + agent_rows, +) from libtmux.experimental.agents.state import AgentState from libtmux.experimental.engines.base import TmuxEngine from libtmux.experimental.ops import ( @@ -54,7 +59,6 @@ from typing_extensions import Self - from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import Agent from libtmux.experimental.models.snapshots import PaneSnapshot from libtmux.experimental.ops import Planner, PlanResult @@ -62,23 +66,9 @@ #: A source of pane snapshots: an engine to read from, or pre-taken snapshots. PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] -#: A source of agent records: a monitor to read its store, or pre-taken records. -AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] MappedT = t.TypeVar("MappedT") KeyT = t.TypeVar("KeyT") -#: Default attention ladder for agent rollups (higher value = more urgent). The -#: ordering is a *documented default* a caller can override per call: surveyed -#: orchestrators disagree on the exact weighting, so it is policy, not a rule. -ATTENTION: dict[AgentState, int] = { - AgentState.AWAITING_INPUT: 5, - AgentState.DONE: 4, - AgentState.IDLE: 3, - AgentState.RUNNING: 2, - AgentState.UNKNOWN: 1, - AgentState.EXITED: 0, -} - def _snapshot_panes(source: PaneSource) -> tuple[PaneSnapshot, ...]: """Resolve *source* into pane snapshots (read an engine, or pass through).""" @@ -373,19 +363,6 @@ def panes() -> PaneQuery: return PaneQuery() -def _query_agents(source: AgentSource) -> tuple[Agent, ...]: - """Resolve *source* into agent records (read a monitor's store, or pass through). - - A monitor is detected by its ``agents`` snapshot property (zero tmux calls -- - the store is already populated by the monitor's own drain); any other value - is taken as a pure sequence of :class:`~..agents.state.Agent` records. - """ - store_agents = getattr(source, "agents", None) - if store_agents is not None: - return tuple(store_agents) - return tuple(t.cast("Sequence[Agent]", source)) - - @dataclass(frozen=True) class AgentQuery: """An immutable, chainable query over agents (the agent twin of PaneQuery). @@ -430,7 +407,7 @@ def limit(self, count: int) -> AgentQuery: def all(self, source: AgentSource) -> tuple[Agent, ...]: """Resolve the query against *source* and return the matched agents.""" - rows: t.Any = QueryList(_query_agents(source)) + rows: t.Any = QueryList(agent_rows(source)) if self.lookups: rows = rows.filter(**self.lookups) rows = list(rows) diff --git a/src/libtmux/experimental/workspace/status.py b/src/libtmux/experimental/workspace/status.py index af3d4f5dc..ad7d8dc70 100644 --- a/src/libtmux/experimental/workspace/status.py +++ b/src/libtmux/experimental/workspace/status.py @@ -5,19 +5,16 @@ import typing as t from dataclasses import dataclass -from libtmux.experimental.query import ATTENTION +from libtmux.experimental.agents.source import ATTENTION, AgentSource, agent_rows if t.TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Iterable - from libtmux.experimental.agents.monitor import AgentMonitor from libtmux.experimental.agents.state import Agent, AgentState from libtmux.experimental.models import ServerSnapshot from libtmux.experimental.models.snapshots import SessionSnapshot from libtmux.experimental.workspace.ir import Workspace -AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] - @dataclass(frozen=True) class WorkspaceStatus: @@ -32,14 +29,6 @@ class WorkspaceStatus: agent_state: AgentState | None = None -def _agent_rows(source: AgentSource) -> tuple[Agent, ...]: - """Resolve an agent source without making a tmux call.""" - store_agents = getattr(source, "agents", None) - if store_agents is not None: - return tuple(store_agents) - return tuple(t.cast("Sequence[Agent]", source)) - - def _session_pane_ids(session: SessionSnapshot) -> set[str]: """Return the pane ids contained in *session*.""" return {pane.pane_id for window in session.windows for pane in window.panes} @@ -76,7 +65,7 @@ def workspace_status( True """ by_name = {session.name: session for session in snapshot.sessions} - agents = _agent_rows(agents_source) + agents = agent_rows(agents_source) statuses: list[WorkspaceStatus] = [] for workspace in workspaces: session = by_name.get(workspace.name) From 7d107301cca728cfaf91fa287b6468c29ef1de31 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:49:08 -0500 Subject: [PATCH 222/223] Agents(refactor[dead]): Delete the unread EVENT_STATE map why: hooks/base.py declared a "canonical" neutral event -> state map that nothing translates through. Every hook installer carries its own vocabulary map (_CLAUDE_EVENT_STATE, _CODEX_EVENT_STATE) and writes the state string straight into its config, so EVENT_STATE was a second source of truth with no reader -- and its only test asserted the literal it was declared with. what: - Drop EVENT_STATE from agents/hooks/base.py; the module is now just the AgentHook protocol, and says so - Drop test_event_state_map_is_canonical (a tautology over the deleted map; the per-agent maps are covered by the installer tests) --- src/libtmux/experimental/agents/hooks/base.py | 32 +++---------------- .../agents/hooks/test_registry.py | 10 +----- 2 files changed, 5 insertions(+), 37 deletions(-) diff --git a/src/libtmux/experimental/agents/hooks/base.py b/src/libtmux/experimental/agents/hooks/base.py index c0b6a07ec..748eccfbe 100644 --- a/src/libtmux/experimental/agents/hooks/base.py +++ b/src/libtmux/experimental/agents/hooks/base.py @@ -1,38 +1,14 @@ -"""AgentHook protocol and canonical lifecycle-event → state map. +"""The protocol every agent hook installer satisfies. -The ``EVENT_STATE`` map translates neutral event names emitted by agent -lifecycle hooks into the :data:`AgentState` string vocabulary understood -by the monitor. ``AgentHook`` is the protocol every hook installer must -satisfy. +Each agent ships its own lifecycle-event vocabulary and its own config format, so +a hook object owns that translation: it knows how to detect its agent, install and +uninstall its hooks, and report their status. """ from __future__ import annotations import typing as t -#: Canonical map from a neutral lifecycle event name to an agent-state string. -#: -#: Keys are the event names that agent hooks fire (e.g. as hook script names); -#: values are the :class:`~libtmux.experimental.agents.state.AgentState` -#: string representations the monitor stores. -#: -#: Examples -#: -------- -#: >>> EVENT_STATE["turn_start"] -#: 'running' -#: >>> EVENT_STATE["needs_approval"] -#: 'awaiting_input' -#: >>> EVENT_STATE["turn_end"] -#: 'done' -#: >>> EVENT_STATE["session_start"] -#: 'idle' -EVENT_STATE: dict[str, str] = { - "turn_start": "running", - "needs_approval": "awaiting_input", - "turn_end": "done", - "session_start": "idle", -} - @t.runtime_checkable class AgentHook(t.Protocol): diff --git a/tests/experimental/agents/hooks/test_registry.py b/tests/experimental/agents/hooks/test_registry.py index 64b6decb5..e763dd258 100644 --- a/tests/experimental/agents/hooks/test_registry.py +++ b/tests/experimental/agents/hooks/test_registry.py @@ -1,20 +1,12 @@ -"""Tests for the hook registry + canonical event map.""" +"""Tests for the hook registry.""" from __future__ import annotations import pytest -from libtmux.experimental.agents.hooks.base import EVENT_STATE from libtmux.experimental.agents.hooks.registry import get, registry -def test_event_state_map_is_canonical() -> None: - """EVENT_STATE maps the four canonical lifecycle events to state strings.""" - assert EVENT_STATE["turn_start"] == "running" - assert EVENT_STATE["needs_approval"] == "awaiting_input" - assert EVENT_STATE["turn_end"] == "done" - - def test_registry_has_claude_and_codex() -> None: """registry() returns at least one hook for claude and one for codex.""" names = {hook.name for hook in registry()} From 50d3f94c366955f68ef31f32c2d4fbdc9e7628d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 11 Jul 2026 08:51:06 -0500 Subject: [PATCH 223/223] Mcp(refactor[dedup]): Fold the workspace-set outcome to_dict why: The workspace-set MCP surface lands on top of the plan tier, so it re-typed the same two shapes the plan tier had just consolidated: the "project a build result into a JSON outcome" step twice, and the "render the outcome as the JSON object a tool returns" dict twice more in the adapter. what: - Give WorkspaceSetOutcome a to_dict(); the adapter's sync and async build_workspaces tools return it - Add plan_tools._to_set_outcome; build_workspaces/abuild_workspaces project through it, matching _to_outcome on the plan path --- .../experimental/mcp/fastmcp_adapter.py | 16 +---- src/libtmux/experimental/mcp/plan_tools.py | 60 +++++++++++-------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index f28a827b6..7c9bdcb0b 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -524,13 +524,7 @@ async def build_workspaces( version=version, preflight=preflight, ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - "sessions": outcome.sessions, - "reused": outcome.reused, - } + return outcome.to_dict() tools.append((execute_plan, "mutating")) tools.append((build_workspace, "mutating")) @@ -577,13 +571,7 @@ def build_workspaces( # type: ignore[misc] version=version, preflight=preflight, ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - "sessions": outcome.sessions, - "reused": outcome.reused, - } + return outcome.to_dict() tools.append((execute_plan, "mutating")) tools.append((build_workspace, "mutating")) diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py index 3e1a0064d..2977fe9a9 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -22,6 +22,7 @@ from libtmux.experimental.mcp.registry import OperationToolRegistry from libtmux.experimental.ops.plan import LazyPlan, PlanResult from libtmux.experimental.ops.planner import Planner + from libtmux.experimental.workspace.sets import WorkspaceSetResult @dataclass(frozen=True) @@ -104,6 +105,27 @@ class WorkspaceSetOutcome: sessions: list[str] reused: list[str] + def to_dict(self) -> dict[str, t.Any]: + """Render as the JSON object an MCP adapter returns to an agent.""" + return { + "ok": self.ok, + "results": self.results, + "bindings": self.bindings, + "sessions": self.sessions, + "reused": self.reused, + } + + +def _to_set_outcome(result: WorkspaceSetResult) -> WorkspaceSetOutcome: + """Project a workspace-set build result into a JSON-friendly outcome.""" + return WorkspaceSetOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.result.results], + bindings=bindings_to_dict(result.bindings), + sessions=list(result.sessions), + reused=list(result.reused), + ) + def execute_plan( plan: LazyPlan, @@ -214,18 +236,13 @@ def build_workspaces( build_workspaces as run_workspaces, ) - result = run_workspaces( - [analyze(spec) for spec in specs], - engine, - version=version, - preflight=preflight, - ) - return WorkspaceSetOutcome( - ok=result.ok, - results=[result_to_dict(item) for item in result.result.results], - bindings=bindings_to_dict(result.bindings), - sessions=list(result.sessions), - reused=list(result.reused), + return _to_set_outcome( + run_workspaces( + [analyze(spec) for spec in specs], + engine, + version=version, + preflight=preflight, + ), ) @@ -242,16 +259,11 @@ async def abuild_workspaces( analyze, ) - result = await arun_workspaces( - [analyze(spec) for spec in specs], - engine, - version=version, - preflight=preflight, - ) - return WorkspaceSetOutcome( - ok=result.ok, - results=[result_to_dict(item) for item in result.result.results], - bindings=bindings_to_dict(result.bindings), - sessions=list(result.sessions), - reused=list(result.reused), + return _to_set_outcome( + await arun_workspaces( + [analyze(spec) for spec in specs], + engine, + version=version, + preflight=preflight, + ), )