diff --git a/CHANGES b/CHANGES index f8457f5ab..59b82fd8a 100644 --- a/CHANGES +++ b/CHANGES @@ -47,6 +47,48 @@ _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 +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 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 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 +{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..9ee1c3283 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -173,3 +173,129 @@ 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()) +``` + +### 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. +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/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. 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/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/pyproject.toml b/pyproject.toml index e481dad46..3f98f17cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,8 @@ 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" +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..586c1f43b --- /dev/null +++ b/src/libtmux/agents/cli.py @@ -0,0 +1,749 @@ +"""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) + engine = AsyncControlModeEngine.for_server(server) + 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) + try: + await monitor.start() + await _redraw_monitor(engine, monitor, config) + try: + await stop.wait() + finally: + await monitor.stop() + finally: + await engine.aclose() + 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.""" + 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( + monitor, + args.pane_id, + _parse_states(args.state), + timeout=args.timeout, + ) + finally: + await monitor.stop() + finally: + await engine.aclose() + 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) + 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( + monitor, + args.pane_id, + text, + wait_ready=not args.no_wait, + timeout=args.timeout, + ) + finally: + await monitor.stop() + finally: + await engine.aclose() + 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/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/src/libtmux/experimental/agents/__init__.py b/src/libtmux/experimental/agents/__init__.py new file mode 100644 index 000000000..9ad81fb78 --- /dev/null +++ b/src/libtmux/experimental/agents/__init__.py @@ -0,0 +1,50 @@ +"""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.statusline import ( + DEFAULT_LABELS, + paint_status_line, + render_status_line, + status_line_op, +) +from libtmux.experimental.agents.wait import ( + AgentWait, + WaitReason, + wait_for_agent_state, + wait_for_agents, +) + +__all__ = ( + "DEFAULT_LABELS", + "Agent", + "AgentMonitor", + "AgentState", + "AgentTransition", + "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/drive.py b/src/libtmux/experimental/agents/drive.py new file mode 100644 index 000000000..1f42aa57a --- /dev/null +++ b/src/libtmux/experimental/agents/drive.py @@ -0,0 +1,276 @@ +"""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.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 +# ``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 AsyncMockEngine + >>> from libtmux.experimental.agents.monitor import AgentMonitor + >>> 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 + 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 AsyncMockEngine + >>> from libtmux.experimental.agents.monitor import AgentMonitor + >>> 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")) + >>> [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/health.py b/src/libtmux/experimental/agents/health.py new file mode 100644 index 000000000..0c21c2b17 --- /dev/null +++ b/src/libtmux/experimental/agents/health.py @@ -0,0 +1,33 @@ +"""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 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 + +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/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/base.py b/src/libtmux/experimental/agents/hooks/base.py new file mode 100644 index 000000000..c0b6a07ec --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/base.py @@ -0,0 +1,104 @@ +"""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"] +#: 'done' +#: >>> EVENT_STATE["session_start"] +#: 'idle' +EVENT_STATE: dict[str, str] = { + "turn_start": "running", + "needs_approval": "awaiting_input", + "turn_end": "done", + "session_start": "idle", +} + + +@t.runtime_checkable +class AgentHook(t.Protocol): + """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 + -------- + >>> import pathlib, tempfile + >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... hook.install() + """ + ... + + def uninstall(self) -> None: + """Remove hook scripts from the agent's config directory. + + Examples + -------- + >>> import pathlib, tempfile + >>> from libtmux.experimental.agents.hooks.claude import ClaudeCodeHook + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... hook.uninstall() + """ + ... + + 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..3681077cc --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/claude.py @@ -0,0 +1,357 @@ +"""Claude Code lifecycle-hook installer. + +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"] +#: 'done' +_CLAUDE_EVENT_STATE: dict[str, str] = { + "UserPromptSubmit": "running", + "Notification": "awaiting_input", + "Stop": "done", + "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. + + 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 + -------- + >>> 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' + >>> (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 ``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 + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... ok = isinstance(hook.detect(), bool) + >>> ok + True + """ + return ( + self._settings_path.parent.exists() and shutil.which("claude") is not None + ) + + def install(self) -> None: + """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 + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = ClaudeCodeHook(settings_path=pathlib.Path(d) / "settings.json") + ... hook.install() + ... status = hook.status() + >>> status + 'installed' + """ + 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: + """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 + -------- + >>> 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' + """ + 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 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 + -------- + >>> 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') + """ + 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/src/libtmux/experimental/agents/hooks/codex.py b/src/libtmux/experimental/agents/hooks/codex.py new file mode 100644 index 000000000..ab7a7f3de --- /dev/null +++ b/src/libtmux/experimental/agents/hooks/codex.py @@ -0,0 +1,358 @@ +"""Codex CLI lifecycle-hook installer. + +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 ``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 → done + 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": "done", + "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 the Codex CLI (OpenAI). + + 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 ``done``). Modern ``[hooks]`` is the + primary mechanism; the ``notify`` path is documented here for future + reference but is not implemented. + + Examples + -------- + >>> 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 ``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 + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... result = hook.detect() + >>> isinstance(result, bool) + True + """ + return self._config_path.parent.exists() and shutil.which("codex") is not None + + def install(self) -> None: + """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 + -------- + >>> import pathlib, tempfile + >>> with tempfile.TemporaryDirectory() as d: + ... hook = CodexHook(config_path=pathlib.Path(d) / "config.toml") + ... hook.install() + ... status = hook.status() + >>> status + 'installed' + """ + content = self._read() + new_block = self._build_block() + if _MARKER_START in 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" + content = content + "\n" + new_block + else: + content = new_block + self._write(content) + + def uninstall(self) -> None: + """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 + -------- + >>> 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' + """ + 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 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 + -------- + >>> 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') + """ + 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/src/libtmux/experimental/agents/hooks/emit.py b/src/libtmux/experimental/agents/hooks/emit.py new file mode 100644 index 000000000..e4ce0e5d8 --- /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] if idx + 1 < len(args) else None + emit(state, name=name) + return 0 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/src/libtmux/experimental/agents/hud.py b/src/libtmux/experimental/agents/hud.py new file mode 100644 index 000000000..1e3e00a5a --- /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 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 __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/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/src/libtmux/experimental/agents/monitor.py b/src/libtmux/experimental/agents/monitor.py new file mode 100644 index 000000000..cfe533c0a --- /dev/null +++ b/src/libtmux/experimental/agents/monitor.py @@ -0,0 +1,852 @@ +"""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 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.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.processes import detect_agent_process +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, + Observed, + Storage, + Vanished, + 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 + from libtmux.experimental.models.snapshots import PaneSnapshot, 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) + + +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. + + 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`. + 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 + -------- + >>> 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, + hud: bool = False, + ) -> 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 + # 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 + # 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: + 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, *, pid: int | None = None) -> 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, + # 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=pid, + ) + 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()) + logger.debug( + "observed agent state %s on pane %s from %s", + reading.state.value, + reading.pane_id, + reading.source, + ) + self._notify_change(before, after) + + # ------------------------------------------------------------------ + # 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), + } + + @property + def waiters(self) -> WaiterRegistry: + """The registry backing :func:`~..wait.wait_for_agent_state`. + + Examples + -------- + >>> from libtmux.experimental.engines import AsyncMockEngine + >>> from libtmux.experimental.agents.wait import WaiterRegistry + >>> isinstance(AgentMonitor(AsyncMockEngine()).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 AsyncMockEngine + >>> from libtmux.experimental.agents.drive import DedupLedger + >>> isinstance(AgentMonitor(AsyncMockEngine()).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 AsyncMockEngine + >>> e = AsyncMockEngine() + >>> 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 AsyncMockEngine + >>> mon = AgentMonitor(AsyncMockEngine()) + >>> 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 AsyncMockEngine + >>> seen = [] + >>> mon = AgentMonitor(AsyncMockEngine()) + >>> 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 + # ------------------------------------------------------------------ + + async def start(self) -> None: + """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. 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. + """ + 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: + result = await self._engine.run( + CommandRequest.from_args("attach-session", "-t", 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) + + # 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() + 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: + """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. 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, 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}") + ) + 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: + """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): + 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()) + + async def reconcile(self) -> None: + """Reconcile the store against the live pane tree (defensive). + + 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: + 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) + # 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 + } + 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) + 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 + + 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 == "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. + + 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 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 + 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 + exits: list[tuple[Agent, Agent]] = [] + 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 + 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( + 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 + # ------------------------------------------------------------------ + + 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. + + 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 + + op = self._hud_renderer.repaint_op(self._hud_pane_id, self._store) + try: + 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.""" + 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. + + 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 + 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: + async for note in stream: + self.ingest(note.raw) + await self._repaint_hud() + 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( + 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/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/src/libtmux/experimental/agents/signals.py b/src/libtmux/experimental/agents/signals.py new file mode 100644 index 000000000..2eea91cdd --- /dev/null +++ b/src/libtmux/experimental/agents/signals.py @@ -0,0 +1,191 @@ +"""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. + + Matches ``%subscription-changed`` notifications for the ``agentstate`` + subscription and extracts the pane and state. Non-matching lines are + silently dropped (``parse`` returns ``None``). + """ + + @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 + -------- + 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] = [] + 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/src/libtmux/experimental/agents/state.py b/src/libtmux/experimental/agents/state.py new file mode 100644 index 000000000..bf2ebe098 --- /dev/null +++ b/src/libtmux/experimental/agents/state.py @@ -0,0 +1,119 @@ +"""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" + DONE = "done" + 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("done") + + >>> 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 + + +@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/statusline.py b/src/libtmux/experimental/agents/statusline.py new file mode 100644 index 000000000..9394be46b --- /dev/null +++ b/src/libtmux/experimental/agents/statusline.py @@ -0,0 +1,140 @@ +"""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.DONE: "done", + 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.DONE, + AgentState.IDLE, + AgentState.RUNNING, + AgentState.UNKNOWN, + AgentState.EXITED, +) + +#: A source of agent records: a monitor (read its ``agents`` snapshot at zero +#: tmux cost), or a pure sequence of :class:`~..state.Agent`. +AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"] + + +def render_status_line( + agents: Sequence[Agent], + *, + 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 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(AsyncMockEngine(), agents, global_=True)) + True + """ + store_agents = getattr(source, "agents", None) + agents: Sequence[Agent] = ( + store_agents + if store_agents is not None + else tuple(t.cast("Sequence[Agent]", source)) + ) + value = render(agents) + op = status_line_op(value, option=option, target=target, global_=global_) + result = await arun(op, engine) + return result.ok diff --git a/src/libtmux/experimental/agents/store.py b/src/libtmux/experimental/agents/store.py new file mode 100644 index 000000000..992a4e3e8 --- /dev/null +++ b/src/libtmux/experimental/agents/store.py @@ -0,0 +1,345 @@ +"""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.from_signal(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. + + Examples + -------- + >>> JsonFile("/nonexistent/path/x.json").load() is None + True + """ + 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. + + 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) + 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/src/libtmux/experimental/agents/tree.py b/src/libtmux/experimental/agents/tree.py new file mode 100644 index 000000000..8baa4223b --- /dev/null +++ b/src/libtmux/experimental/agents/tree.py @@ -0,0 +1,87 @@ +"""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_floating_flag", + "pane_pid", + "pane_current_command", + "pane_title", + "@agent_state", + "@agent_name", +) + + +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/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/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 2fee21f50..9563f643c 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -329,12 +329,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: diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index c754b0516..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, @@ -60,6 +64,7 @@ new_pane, rename_session, rename_window, + run_in_pane, select_layout, select_pane, send_input, @@ -278,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", @@ -301,6 +309,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/_lifespan.py b/src/libtmux/experimental/mcp/_lifespan.py index 2008fa06e..cbec8fa24 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,13 @@ 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: + try: + await monitor.start() + yield + finally: + await monitor.stop() + else: + yield return _lifespan 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 de22a01d5..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( @@ -122,6 +128,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.""" ... @@ -218,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, @@ -238,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) @@ -258,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. @@ -265,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: @@ -293,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 @@ -302,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( @@ -310,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( @@ -404,7 +435,105 @@ 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 + + +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, + *, + 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: @@ -418,6 +547,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: @@ -462,6 +592,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``). @@ -470,65 +604,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, - ) - 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, + needle=needle, + 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 e79a01457..123f78c4e 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"), @@ -141,12 +142,13 @@ 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 += ( - " 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 " @@ -522,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] @@ -562,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( @@ -748,16 +795,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 +839,9 @@ def build_async_server( register_resources(mcp, engine, is_async=True) register_events(mcp, engine, mode=events, source=event_source) + if monitor_enabled and lifespan: + 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/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/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/agents.py b/src/libtmux/experimental/mcp/vocabulary/agents.py new file mode 100644 index 000000000..d44db0a21 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/agents.py @@ -0,0 +1,391 @@ +"""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 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.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) + + # ------------------------------------------------------------------ + # 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. + + 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 observe 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} + await asyncio.sleep(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"} + + 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( + 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 + ), + ), + ) + + # ------------------------------------------------------------------ + # 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,done"``). + 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``/``done``/``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..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 @@ -158,22 +164,90 @@ 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, + 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 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, ) - ).raise_for_status() async def acapture_pane( @@ -635,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/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/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", ) 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/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/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_claude.py b/tests/experimental/agents/hooks/test_claude.py new file mode 100644 index 000000000..d455cb2f2 --- /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 done" 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}" diff --git a/tests/experimental/agents/hooks/test_codex.py b/tests/experimental/agents/hooks/test_codex.py new file mode 100644 index 000000000..501c2e2de --- /dev/null +++ b/tests/experimental/agents/hooks/test_codex.py @@ -0,0 +1,86 @@ +"""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 "libtmux-agent-emit done" 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() + + +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 + + +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" diff --git a/tests/experimental/agents/hooks/test_emit.py b/tests/experimental/agents/hooks/test_emit.py new file mode 100644 index 000000000..0eecf4302 --- /dev/null +++ b/tests/experimental/agents/hooks/test_emit.py @@ -0,0 +1,67 @@ +"""Tests for the shared agent-state emitter.""" + +from __future__ import annotations + +import pathlib +import typing as t + +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: + """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 + + +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 diff --git a/tests/experimental/agents/hooks/test_registry.py b/tests/experimental/agents/hooks/test_registry.py new file mode 100644 index 000000000..64b6decb5 --- /dev/null +++ b/tests/experimental/agents/hooks/test_registry.py @@ -0,0 +1,27 @@ +"""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" + assert EVENT_STATE["turn_end"] == "done" + + +def test_registry_has_claude_and_codex() -> None: + """registry() returns at least one hook for claude and one for codex.""" + names = {hook.name for hook in registry()} + 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") diff --git a/tests/experimental/agents/test_cli.py b/tests/experimental/agents/test_cli.py new file mode 100644 index 000000000..a05011d90 --- /dev/null +++ b/tests/experimental/agents/test_cli.py @@ -0,0 +1,329 @@ +"""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] + + +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_drive.py b/tests/experimental/agents/test_drive.py new file mode 100644 index 000000000..d26da8b3b --- /dev/null +++ b/tests/experimental/agents/test_drive.py @@ -0,0 +1,155 @@ +"""Unit tests for the drive verbs: lock, dedup, folding (no live tmux).""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.agents.drive import ( + READY_STATES, + DedupLedger, + SendOutcome, + pane_lock, + send_to_agent, + 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 + + +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_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.""" + + 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_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 diff --git a/tests/experimental/agents/test_hud.py b/tests/experimental/agents/test_hud.py new file mode 100644 index 000000000..b034fc14d --- /dev/null +++ b/tests/experimental/agents/test_hud.py @@ -0,0 +1,177 @@ +"""Tests for the floating agent HUD (renderer + monitor lifecycle).""" + +from __future__ import annotations + +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 +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, ...] = (), + *, + 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: + 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 == "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) + 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 + + +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 diff --git a/tests/experimental/agents/test_live_monitor.py b/tests/experimental/agents/test_live_monitor.py new file mode 100644 index 000000000..d11df7518 --- /dev/null +++ b/tests/experimental/agents/test_live_monitor.py @@ -0,0 +1,193 @@ +"""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 + + +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. + + 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()) + + +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_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] diff --git a/tests/experimental/agents/test_monitor.py b/tests/experimental/agents/test_monitor.py new file mode 100644 index 000000000..e726f59cd --- /dev/null +++ b/tests/experimental/agents/test_monitor.py @@ -0,0 +1,347 @@ +"""Unit tests for AgentMonitor.ingest (no live tmux).""" + +from __future__ import annotations + +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 +from libtmux.experimental.engines.base import CommandRequest, CommandResult +from libtmux.experimental.models.snapshots import PaneSnapshot + + +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: ... + + +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: ... + + +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). + + 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_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_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()) + 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_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()) + 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 + + +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 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" diff --git a/tests/experimental/agents/test_state.py b/tests/experimental/agents/test_state.py new file mode 100644 index 000000000..77cbeb81b --- /dev/null +++ b/tests/experimental/agents/test_state.py @@ -0,0 +1,30 @@ +"""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("done") is AgentState.DONE + 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 diff --git a/tests/experimental/agents/test_statusline.py b/tests/experimental/agents/test_statusline.py new file mode 100644 index 000000000..6c50860ec --- /dev/null +++ b/tests/experimental/agents/test_statusline.py @@ -0,0 +1,120 @@ +"""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 AsyncMockEngine +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), + _agent("%5", AgentState.DONE), + ] + assert render_status_line(rows) == "wait:1 done: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(AsyncMockEngine()) + 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"] diff --git a/tests/experimental/agents/test_store.py b/tests/experimental/agents/test_store.py new file mode 100644 index 000000000..b2c601b9c --- /dev/null +++ b/tests/experimental/agents/test_store.py @@ -0,0 +1,116 @@ +"""Tests for the durable store + reducer.""" + +from __future__ import annotations + +import json +import pathlib +import typing as t + +import pytest + +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"] + + +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("done_round_trips", "done", AgentState.DONE), + 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 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_tree.py b/tests/experimental/agents/test_tree.py new file mode 100644 index 000000000..0562607a4 --- /dev/null +++ b/tests/experimental/agents/test_tree.py @@ -0,0 +1,39 @@ +"""Tests for the derived tmux tree helpers.""" + +from __future__ import annotations + +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 = [ + { + "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"] diff --git a/tests/experimental/agents/test_wait.py b/tests/experimental/agents/test_wait.py new file mode 100644 index 000000000..d152bdee1 --- /dev/null +++ b/tests/experimental/agents/test_wait.py @@ -0,0 +1,186 @@ +"""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_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.""" + + 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/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_agents_tools.py b/tests/experimental/mcp/test_agents_tools.py new file mode 100644 index 000000000..148e7b15f --- /dev/null +++ b/tests/experimental/mcp/test_agents_tools.py @@ -0,0 +1,174 @@ +"""Tests for the agents MCP tools (callables driven directly).""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.agents.monitor import AgentMonitor + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +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: ... + + +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 : done") + listing = [{"pane_id": a.pane_id, "state": a.state.value} for a in mon.agents] + assert {"pane_id": "%1", "state": "done"} 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(t.cast("FastMCP[t.Any]", 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 + + +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 + + +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 : done") + + wait = mcp.tools["wait_for_agent"].fn + 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: + """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 diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 7b95ad244..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 @@ -26,6 +27,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 @@ -210,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 @@ -248,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 @@ -291,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 @@ -314,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 @@ -373,6 +519,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_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 5af9a1440..409e4b421 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"]}]}, + ], + MockEngine(), + 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_monitor_live.py b/tests/experimental/mcp/test_monitor_live.py index 4f55d5805..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") @@ -71,3 +72,63 @@ 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": 8, + "timeout": 2.0, + }, + ) + finally: + await producer + return result.data + + data = asyncio.run(main()) + 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 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")), ) 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 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.""" 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", + ) 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" diff --git a/tests/experimental/test_query_agents.py b/tests/experimental/test_query_agents.py new file mode 100644 index 000000000..022e64b6a --- /dev/null +++ b/tests/experimental/test_query_agents.py @@ -0,0 +1,125 @@ +"""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 AsyncMockEngine +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(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) + 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_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 + + +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}