From 3235e937683207aaf0083db794232b4d462103ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:28:07 -0500 Subject: [PATCH 01/56] 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 ada4bbff1728032120c8c3cb897bb9fc8f61c54b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:30:41 -0500 Subject: [PATCH 02/56] 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 6e3985a0664e7fc1b2e1e9cbb5f5cc66af95f422 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:37:36 -0500 Subject: [PATCH 03/56] 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 95521fba218ffad7b6bcf9408b9fc87155f56269 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:40:47 -0500 Subject: [PATCH 04/56] 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 eb80aca590cdf08794652a4c7cdaace60bbb2508 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:42:06 -0500 Subject: [PATCH 05/56] 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 a97273f3eb0e08b13dc5b4723ac5a630a9d8b34d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:46:02 -0500 Subject: [PATCH 06/56] 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 3ecc37127b49ba9b604de194b168dc164c77af70 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:50:28 -0500 Subject: [PATCH 07/56] 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 39b180df76e5ea2c76bae7a7241d5396db78903c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:53:45 -0500 Subject: [PATCH 08/56] 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 d7edec0c2640bf1d1595dd0f4fde71bbbdde79ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:57:50 -0500 Subject: [PATCH 09/56] 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 48793f1e82631926c5aa43575839df5086c0c82f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:12:04 -0500 Subject: [PATCH 10/56] 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 399d85a5132ac8ad25d023297d4fb7a4de16baec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:18:02 -0500 Subject: [PATCH 11/56] 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 84b82d4d33b97a33bce01394bd75930c556a8db9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:22:40 -0500 Subject: [PATCH 12/56] 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 e481dad46..b60c4f156 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 93da8abde65139894435e43044318d92928bc862 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:29:11 -0500 Subject: [PATCH 13/56] =?UTF-8?q?Agents(feat[hooks]):=20Add=20AgentHook=20?= =?UTF-8?q?protocol=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 a40f51100d61f9caa91389009884c9fb07b28cd2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:39:00 -0500 Subject: [PATCH 14/56] 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 1a685c59045f7ad2ccf9291a8d2718be9ff3fc5b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:46:34 -0500 Subject: [PATCH 15/56] 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 8a54e6e5d2a448bf213e159a647478afab6c3caf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:52:02 -0500 Subject: [PATCH 16/56] 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 e79a01457..4ded167cd 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -748,16 +748,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) @@ -781,5 +792,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 58d3de67074ab04c4e0c0c0e98fbefd32f98e06b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 06:58:32 -0500 Subject: [PATCH 17/56] 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 85341602f35976f928394012f183589ae1cfb521 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:14:26 -0500 Subject: [PATCH 18/56] 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 f8457f5ab..6222015e8 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 5c5427f1882dcf9adf10dd2eb436e57b0c0a1e72 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:22:53 -0500 Subject: [PATCH 19/56] 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 d0d98ce1755432896919f21ae0fc45e6d182aa51 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:28:57 -0500 Subject: [PATCH 20/56] 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 92d33342d04532bab6c4476f27a9a28dbd8d9369 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:30:14 -0500 Subject: [PATCH 21/56] 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 31a9d06eb8ba5f8205f73a1548a25017e3038557 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:31:38 -0500 Subject: [PATCH 22/56] 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 4ded167cd..62d6dc737 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -792,7 +792,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 76d01927bff369aed4e337d31349727a09aa8224 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:32:38 -0500 Subject: [PATCH 23/56] 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 903d09bf43d4b979c8ab2c0c54add4e08c32de3a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:35:17 -0500 Subject: [PATCH 24/56] 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 c7b92c6de3eee7e632e735cb8abf4424b96f7dab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:36:41 -0500 Subject: [PATCH 25/56] 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 6f1f1f02d93dc40fad277154d40afac14213090f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:38:09 -0500 Subject: [PATCH 26/56] 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 45ae1a1812f06730ea2913a41cbfafdbd26f7595 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:39:07 -0500 Subject: [PATCH 27/56] 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 3c2eec32e331d713dcf8a58dbbe073294d1db6bc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:40:56 -0500 Subject: [PATCH 28/56] 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 7aba09c1ca6e697e0c0145b3837acf21932192d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 07:49:31 -0500 Subject: [PATCH 29/56] 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 39393032e21b317ebaab05015184b56b28d40f08 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:47:01 -0500 Subject: [PATCH 30/56] 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 45edca825a6580ed294822cc55ccdcb412b068ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 17:00:57 -0500 Subject: [PATCH 31/56] 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 fb60b532952b159ed1321da0ab30aa756e439172 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:34:15 -0500 Subject: [PATCH 32/56] 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 9fb1aa4d063c83d7b7fa74e61e4be940f996ae72 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:39:07 -0500 Subject: [PATCH 33/56] 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 dfb5af709220f34f3354b54d0a9e29e51a674d3c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:41:54 -0500 Subject: [PATCH 34/56] 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 3f26066465be2bdc63d44b8f7eafb9e90f9f56cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:47:18 -0500 Subject: [PATCH 35/56] 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 53eb2ecee6c7796df628c8cc204572c02579e7a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 19:49:58 -0500 Subject: [PATCH 36/56] 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 6222015e8..8d690a4a5 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 d27ed79ab7be06366a47738f4b7bd63660cdd6fe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:57:56 -0500 Subject: [PATCH 37/56] 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 021c19dae1dfc4b5993aed830f991d1465e2a19c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:03:09 -0500 Subject: [PATCH 38/56] 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 4283945af57a99a4ef71f6b97599dcba28194420 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:37:49 -0500 Subject: [PATCH 39/56] 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 d01c714743c527e71f89144870e9ae41ec9d6359 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:42:00 -0500 Subject: [PATCH 40/56] 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 fbc0ee946a952b2244f8d1096f4618cb5c32c2a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:48:53 -0500 Subject: [PATCH 41/56] 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 8d690a4a5..242c64f67 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 6f3ea01703651a8d28041b448cb22d58fc919f76 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 04:26:45 -0500 Subject: [PATCH 42/56] 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 17c9a88e0801d0ba59ae57c05cdb460dbe201a56 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 04:39:59 -0500 Subject: [PATCH 43/56] 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 99bc9d93905199d512733d388121f63d432323df Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:06:11 -0500 Subject: [PATCH 44/56] 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 fb0cbaf72dd29426ddfd513df28a852ca9538a39 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:09:51 -0500 Subject: [PATCH 45/56] 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. --- .../experimental/engines/control_mode.py | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 2fee21f50..d6e26c5be 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -27,7 +27,6 @@ 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,19 +187,6 @@ 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] @@ -329,12 +315,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 78b0fae2b77462b92156d58dbce607bbda36ed13 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:10:35 -0500 Subject: [PATCH 46/56] 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 ecb3221915ab584c4ec611da2fb6748f9d2280db Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:12:03 -0500 Subject: [PATCH 47/56] 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 732ccdbb7e88550e408b01cdae6a6ffe1c9f12bb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:37:15 -0500 Subject: [PATCH 48/56] 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 62d6dc737..36cd9d51c 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -47,6 +47,7 @@ ("split_pane", "mutating"), ("new_pane", "mutating"), ("send_input", "mutating"), + ("run_in_pane", "mutating"), ("capture_pane", "readonly"), ("capture_active_pane", "readonly"), ("grep_pane", "readonly"), @@ -145,8 +146,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 = [ @@ -175,12 +177,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 1c7ecfea8bb828ad575dc39113101b199828d5a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:42:22 -0500 Subject: [PATCH 49/56] 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 d9c65e1a2dee75a8d5bc9e1fa72cf2fb71196a1d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:45:43 -0500 Subject: [PATCH 50/56] 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 36cd9d51c..123f78c4e 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -142,7 +142,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 += ( @@ -527,8 +527,29 @@ async def build_workspace( "bindings": outcome.bindings, } + 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] @@ -567,8 +588,29 @@ def build_workspace( # type: ignore[misc] "bindings": outcome.bindings, } + 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 fb503af08..79dcf2add 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -81,6 +81,17 @@ class PlanOutcome: bindings: dict[str, str] +@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, @@ -191,3 +202,59 @@ async def abuild_workspace( results=[result_to_dict(item) for item in result.results], bindings=bindings_to_dict(result.bindings), ) + + +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 8dc95878b8fbdfc357df2b53d9dc1c77146d58f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:55:28 -0500 Subject: [PATCH 51/56] 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 8c143a89c962d7c519eeaf089641fd98f283ff66 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 05:57:54 -0500 Subject: [PATCH 52/56] 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 242c64f67..59b82fd8a 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 b60c4f156..3f98f17cb 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 43f794d13fd101aa963d103e245ddb9c4ed80e29 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 06:07:59 -0500 Subject: [PATCH 53/56] 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 92746217610496c5db8996d76af645e0eb3b40d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 06:10:53 -0500 Subject: [PATCH 54/56] 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 66712f57e30c40d3d74263d30e7e18433e587176 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 15:49:42 -0500 Subject: [PATCH 55/56] 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 --- .../experimental/engines/control_mode.py | 14 ++ src/libtmux/experimental/query.py | 167 ++++++++++++++++++ .../experimental/workspace/__init__.py | 3 + 3 files changed, 184 insertions(+) diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index d6e26c5be..9563f643c 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -27,6 +27,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: @@ -187,6 +188,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/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 ca13dfd146f635c105b020698a5ce3585408b5e3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 17:53:45 -0500 Subject: [PATCH 56/56] 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)