Skip to content

Commit 2f87282

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 573fdb0 commit 2f87282

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

src/libtmux/experimental/engines/control_mode.py

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

2929
from libtmux import exc
30+
from libtmux.common import get_version
3031
from libtmux.experimental.engines.base import CommandResult, render_control_line
3132

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

191+
def tmux_version(self) -> str | None:
192+
"""Report the connected server's tmux version (``tmux -V``).
193+
194+
Implements
195+
:class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so
196+
version-gated operations render correctly over control mode; in-memory
197+
engines omit it and resolution assumes latest.
198+
"""
199+
try:
200+
return str(get_version(self.tmux_bin))
201+
except exc.LibTmuxException:
202+
return None
203+
190204
def run(self, request: CommandRequest) -> CommandResult:
191205
"""Execute one tmux command over the control connection."""
192206
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
@@ -32,6 +32,7 @@
3232
from dataclasses import dataclass, field
3333

3434
from libtmux._internal.query_list import QueryList
35+
from libtmux.experimental.agents.state import AgentState
3536
from libtmux.experimental.engines.base import TmuxEngine
3637
from libtmux.experimental.ops import (
3738
ClearHistory,
@@ -53,13 +54,30 @@
5354

5455
from typing_extensions import Self
5556

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

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

6482

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