Skip to content

Commit 18ea685

Browse files
committed
Rebase(fixup): Reconcile supatui onto the fluent engine-ops
why: rebasing the agent-monitor branch onto the fluent engine-ops merged two divergent versions of query.py and the control engines. The mechanical rebase resolution kept engine-ops's split-type pane handles and supatui's fuller async engine; this restores what each side dropped. what: - Re-graft the agents() query (AgentQuery/agents()/ATTENTION/_query_agents) onto the split-type query.py - Re-add tmux_version() to both control engines (kept supatui's engine, which lacked it) - Export workspace_status from the workspace package
1 parent ac3e101 commit 18ea685

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

src/libtmux/experimental/engines/async_control_mode.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from dataclasses import dataclass, field
4141

4242
from libtmux import exc
43+
from libtmux.common import get_version
4344
from libtmux.experimental.engines.base import render_control_line
4445
from libtmux.experimental.engines.control_mode import (
4546
ControlModeError,
@@ -201,6 +202,19 @@ def __init__(
201202
self._connected = asyncio.Event()
202203
self._spawn_error: BaseException | None = None
203204

205+
def tmux_version(self) -> str | None:
206+
"""Report the connected server's tmux version (``tmux -V``).
207+
208+
Implements
209+
:class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so
210+
version-gated operations render correctly over control mode; in-memory
211+
engines omit it and resolution assumes latest.
212+
"""
213+
try:
214+
return str(get_version(self.tmux_bin))
215+
except exc.LibTmuxException:
216+
return None
217+
204218
def add_subscription(self, spec: str) -> None:
205219
"""Record a desired ``refresh-client -B`` subscription (idempotent).
206220

src/libtmux/experimental/engines/control_mode.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import typing as t
2929

3030
from libtmux import exc
31+
from libtmux.common import get_version
3132
from libtmux.experimental.engines.base import CommandResult, render_control_line
3233

3334
if t.TYPE_CHECKING:
@@ -188,6 +189,19 @@ def __init__(
188189
self._proc: subprocess.Popen[bytes] | None = None
189190
self._selector: selectors.DefaultSelector | None = None
190191

192+
def tmux_version(self) -> str | None:
193+
"""Report the connected server's tmux version (``tmux -V``).
194+
195+
Implements
196+
:class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so
197+
version-gated operations render correctly over control mode; in-memory
198+
engines omit it and resolution assumes latest.
199+
"""
200+
try:
201+
return str(get_version(self.tmux_bin))
202+
except exc.LibTmuxException:
203+
return None
204+
191205
def run(self, request: CommandRequest) -> CommandResult:
192206
"""Execute one tmux command over the control connection."""
193207
return self.run_batch([request])[0]

src/libtmux/experimental/query.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from dataclasses import dataclass, field, replace
3232

3333
from libtmux._internal.query_list import QueryList
34+
from libtmux.experimental.agents.state import AgentState
3435
from libtmux.experimental.engines.base import TmuxEngine
3536
from libtmux.experimental.ops import (
3637
ClearHistory,
@@ -52,13 +53,30 @@
5253

5354
from typing_extensions import Self
5455

56+
from libtmux.experimental.agents.monitor import AgentMonitor
57+
from libtmux.experimental.agents.state import Agent
5558
from libtmux.experimental.models.snapshots import PaneSnapshot
5659
from libtmux.experimental.ops import Planner, PlanResult
5760
from libtmux.experimental.ops._types import SlotRef, Target
5861

5962
#: A source of pane snapshots: an engine to read from, or pre-taken snapshots.
6063
PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"]
64+
#: A source of agent records: a monitor to read its store, or pre-taken records.
65+
AgentSource = t.Union["AgentMonitor", "Sequence[Agent]"]
6166
MappedT = t.TypeVar("MappedT")
67+
KeyT = t.TypeVar("KeyT")
68+
69+
#: Default attention ladder for agent rollups (higher value = more urgent). The
70+
#: ordering is a *documented default* a caller can override per call: surveyed
71+
#: orchestrators disagree on the exact weighting, so it is policy, not a rule.
72+
ATTENTION: dict[AgentState, int] = {
73+
AgentState.AWAITING_INPUT: 5,
74+
AgentState.DONE: 4,
75+
AgentState.IDLE: 3,
76+
AgentState.RUNNING: 2,
77+
AgentState.UNKNOWN: 1,
78+
AgentState.EXITED: 0,
79+
}
6280

6381

6482
def _snapshot_panes(source: PaneSource) -> tuple[PaneSnapshot, ...]:
@@ -352,3 +370,152 @@ def panes() -> PaneQuery:
352370
PaneQuery(lookups={'active': True}, order='pane_index', limit_count=1)
353371
"""
354372
return PaneQuery()
373+
374+
375+
def _query_agents(source: AgentSource) -> tuple[Agent, ...]:
376+
"""Resolve *source* into agent records (read a monitor's store, or pass through).
377+
378+
A monitor is detected by its ``agents`` snapshot property (zero tmux calls --
379+
the store is already populated by the monitor's own drain); any other value
380+
is taken as a pure sequence of :class:`~..agents.state.Agent` records.
381+
"""
382+
store_agents = getattr(source, "agents", None)
383+
if store_agents is not None:
384+
return tuple(store_agents)
385+
return tuple(t.cast("Sequence[Agent]", source))
386+
387+
388+
@dataclass(frozen=True)
389+
class AgentQuery:
390+
"""An immutable, chainable query over agents (the agent twin of PaneQuery).
391+
392+
Resolves against an :data:`AgentSource` -- an
393+
:class:`~..agents.monitor.AgentMonitor` (read straight from its in-process
394+
store, **zero tmux calls**) or a pure sequence of
395+
:class:`~..agents.state.Agent` records. Each method returns a new query;
396+
:meth:`all` / :meth:`first` resolve it.
397+
398+
Examples
399+
--------
400+
>>> from libtmux.experimental.agents.state import Agent, AgentState
401+
>>> rows = [
402+
... Agent(pane_id="%1", key="%1", name="claude",
403+
... state=AgentState.AWAITING_INPUT, since=0.0, source="option",
404+
... pid=None, alive=True),
405+
... Agent(pane_id="%2", key="%2", name="codex",
406+
... state=AgentState.RUNNING, since=0.0, source="option",
407+
... pid=None, alive=True),
408+
... ]
409+
>>> agents().filter(state=AgentState.AWAITING_INPUT).map(
410+
... lambda a: a.pane_id).all(rows)
411+
('%1',)
412+
"""
413+
414+
lookups: Mapping[str, t.Any] = field(default_factory=dict)
415+
order: str | None = None
416+
limit_count: int | None = None
417+
418+
def filter(self, **lookups: t.Any) -> AgentQuery:
419+
"""Narrow by QueryList lookups (e.g. ``state=AgentState.IDLE``, ``name=``)."""
420+
return replace(self, lookups={**self.lookups, **lookups})
421+
422+
def order_by(self, field_name: str) -> AgentQuery:
423+
"""Sort the results by an Agent attribute (missing values last)."""
424+
return replace(self, order=field_name)
425+
426+
def limit(self, count: int) -> AgentQuery:
427+
"""Keep only the first *count* results."""
428+
return replace(self, limit_count=count)
429+
430+
def all(self, source: AgentSource) -> tuple[Agent, ...]:
431+
"""Resolve the query against *source* and return the matched agents."""
432+
rows: t.Any = QueryList(_query_agents(source))
433+
if self.lookups:
434+
rows = rows.filter(**self.lookups)
435+
rows = list(rows)
436+
if self.order is not None:
437+
rows.sort(key=lambda agent: _order_key(agent, self.order))
438+
if self.limit_count is not None:
439+
rows = rows[: self.limit_count]
440+
return tuple(rows)
441+
442+
def first(self, source: AgentSource) -> Agent | None:
443+
"""Return the first matched agent, or ``None`` when none match."""
444+
rows = self.all(source)
445+
return rows[0] if rows else None
446+
447+
def map(self, fn: Callable[[Agent], MappedT]) -> MappedAgentQuery[MappedT]:
448+
"""Project each matched agent through *fn* (a pure read projection)."""
449+
return MappedAgentQuery(self, fn)
450+
451+
def most_urgent(
452+
self,
453+
source: AgentSource,
454+
*,
455+
priority: Mapping[AgentState, int] = ATTENTION,
456+
) -> Agent | None:
457+
"""Return the matched agent whose state ranks highest in *priority*.
458+
459+
The "jump to the agent that needs me" primitive (ties keep input order);
460+
``None`` when nothing matches. *priority* defaults to :data:`ATTENTION`.
461+
"""
462+
rows = self.all(source)
463+
if not rows:
464+
return None
465+
return max(rows, key=lambda agent: priority.get(agent.state, -1))
466+
467+
def rollup(
468+
self,
469+
source: AgentSource,
470+
*,
471+
key: Callable[[Agent], KeyT],
472+
priority: Mapping[AgentState, int] = ATTENTION,
473+
) -> dict[KeyT, AgentState]:
474+
"""Collapse each ``key(agent)`` group to its most-urgent state.
475+
476+
The fleet "who needs me" read model: group the matched agents by *key*
477+
(e.g. ``lambda a: a.name``) and report, per group, the state with the
478+
highest *priority*. *priority* defaults to :data:`ATTENTION` and is
479+
overridable -- the weighting is policy, not a fixed rule.
480+
"""
481+
best_rank: dict[KeyT, int] = {}
482+
out: dict[KeyT, AgentState] = {}
483+
for agent in self.all(source):
484+
group = key(agent)
485+
rank = priority.get(agent.state, -1)
486+
if group not in best_rank or rank > best_rank[group]:
487+
best_rank[group] = rank
488+
out[group] = agent.state
489+
return out
490+
491+
492+
@dataclass(frozen=True)
493+
class MappedAgentQuery(t.Generic[MappedT]):
494+
"""An :class:`AgentQuery` whose rows are projected through a function."""
495+
496+
query: AgentQuery
497+
fn: Callable[[Agent], MappedT]
498+
499+
def all(self, source: AgentSource) -> tuple[MappedT, ...]:
500+
"""Resolve and project every matched agent."""
501+
return tuple(self.fn(agent) for agent in self.query.all(source))
502+
503+
def first(self, source: AgentSource) -> MappedT | None:
504+
"""Resolve and project the first matched agent, or ``None``."""
505+
first = self.query.first(source)
506+
return self.fn(first) if first is not None else None
507+
508+
509+
def agents() -> AgentQuery:
510+
"""Start a query over tracked coding agents (the agent twin of :func:`panes`).
511+
512+
Resolve it against an :class:`~..agents.monitor.AgentMonitor` (zero tmux
513+
calls -- the monitor's store is already live) or a pure sequence of
514+
:class:`~..agents.state.Agent` records.
515+
516+
Examples
517+
--------
518+
>>> agents().filter(name="claude").limit(1)
519+
AgentQuery(lookups={'name': 'claude'}, order=None, limit_count=1)
520+
"""
521+
return AgentQuery()

src/libtmux/experimental/workspace/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
build_workspaces,
6262
compile_workspaces,
6363
)
64+
from libtmux.experimental.workspace.status import WorkspaceStatus, workspace_status
6465

6566
__all__ = (
6667
"BuildEvent",
@@ -81,6 +82,7 @@
8182
"WorkspaceCompileError",
8283
"WorkspaceSet",
8384
"WorkspaceSetResult",
85+
"WorkspaceStatus",
8486
"abuild_workspace",
8587
"abuild_workspaces",
8688
"afreeze_server",
@@ -94,4 +96,5 @@
9496
"expand",
9597
"freeze",
9698
"freeze_server",
99+
"workspace_status",
97100
)

0 commit comments

Comments
 (0)