fleetwatcher 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fleetwatch/core.py ADDED
@@ -0,0 +1,241 @@
1
+ """The aggregator: poll every adapter, hold the current fleet, sort it.
2
+
3
+ This is the only place that knows about all the adapters at once. It scans them
4
+ on demand (``refresh``), keeps one normalized ``SessionState`` per session,
5
+ re-ages quiet sessions cheaply, drops the ones that vanished, and dispatches
6
+ plain-language summaries for whatever needs attention. The TUI and the CLI both
7
+ talk to exactly this surface:
8
+
9
+ refresh() re-scan; cheap; safe to call on an interval
10
+ sessions() current fleet, sorted needs-first then most-recent
11
+ counts() {"active": n, "waiting": n, ..., "total": n}
12
+ request_summary(key) force a model summary for one session (the TUI 's' key)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ import time
19
+ from typing import Optional
20
+
21
+ from . import config
22
+ from .adapters import all_adapters
23
+ from .adapters.base import LocalSource, Source
24
+ from .models import SessionState, State
25
+ from .remote import parse_hosts
26
+ from .summarize import Summarizer
27
+
28
+ # Sort priority: the things that want you, first.
29
+ _STATE_ORDER = {
30
+ State.WAITING: 0,
31
+ State.ERROR: 1,
32
+ State.ACTIVE: 2,
33
+ State.IDLE: 3,
34
+ State.DONE: 4,
35
+ }
36
+
37
+ # States that can change as time passes even when the file does not, so they
38
+ # must be re-read on every refresh rather than trusting the mtime cache.
39
+ _VOLATILE = {State.ACTIVE, State.WAITING}
40
+
41
+
42
+ class Aggregator:
43
+ def __init__(
44
+ self,
45
+ source: Optional[Source] = None,
46
+ summarizer: Optional[Summarizer] = None,
47
+ hosts: Optional[list] = None,
48
+ ):
49
+ self.source = source or LocalSource()
50
+ self.adapters = all_adapters()
51
+ self.summarizer = summarizer if summarizer is not None else Summarizer()
52
+ # Remote hosts watched over ssh. Any object with `.name` and `.fetch()`
53
+ # works (tests inject fakes); by default parsed from config.
54
+ self.hosts = hosts if hosts is not None else parse_hosts(config.REMOTE_HOSTS)
55
+ self._states: dict[tuple, SessionState] = {}
56
+ self._mtimes: dict[tuple, float] = {}
57
+ self._lock = threading.Lock()
58
+
59
+ # --- public surface ---
60
+
61
+ def refresh(self) -> None:
62
+ now = time.time()
63
+ seen: set[tuple] = set()
64
+
65
+ for adapter in self.adapters:
66
+ try:
67
+ refs = adapter.discover(self.source)
68
+ except Exception:
69
+ refs = [] # a broken adapter must never sink the whole scan
70
+ for ref in refs:
71
+ key = (self.source.host, adapter.vendor, ref.session_id)
72
+ # Prefer the adapter's freshness hint (newest mtime across a
73
+ # multi-file session) so a change in any of the session's files
74
+ # is noticed, not just the primary transcript's.
75
+ mtime = (
76
+ ref.mtime
77
+ if ref.mtime is not None
78
+ else self.source.mtime(ref.path)
79
+ )
80
+
81
+ # Skip files too old to care about without ever reading them —
82
+ # this is what keeps a refresh cheap when hundreds of stale
83
+ # transcripts sit on disk. Not adding to `seen` lets eviction
84
+ # drop one that just aged out.
85
+ if mtime and (now - mtime) > config.MAX_AGE:
86
+ continue
87
+
88
+ seen.add(key)
89
+ prev = self._states.get(key)
90
+
91
+ unchanged = prev is not None and self._mtimes.get(key) == mtime
92
+ if unchanged and prev.state not in _VOLATILE:
93
+ self._reage(prev, now)
94
+ continue
95
+
96
+ try:
97
+ state = adapter.read(self.source, ref, prev)
98
+ except Exception as exc:
99
+ state = self._error_state(adapter.vendor, ref, str(exc))
100
+ if state is None:
101
+ continue
102
+
103
+ # Carry a model summary across re-reads of the same activity.
104
+ if not state.summary:
105
+ if prev and prev.summary and prev.last_activity == state.last_activity:
106
+ state.summary = prev.summary
107
+ else:
108
+ cached = self.summarizer.cached(state)
109
+ if cached:
110
+ state.summary = cached
111
+
112
+ with self._lock:
113
+ self._states[key] = state
114
+ self._mtimes[key] = mtime
115
+
116
+ self._fetch_remotes(seen)
117
+ self._evict(seen, now)
118
+ self._dispatch_summaries()
119
+
120
+ def _fetch_remotes(self, seen: set) -> None:
121
+ """Pull each remote host's JSON export and merge it in. An unreachable
122
+ host keeps its last-known sessions (marked seen so eviction spares them)
123
+ rather than blinking out of existence."""
124
+ for host in self.hosts:
125
+ try:
126
+ states, reachable = host.fetch()
127
+ except Exception:
128
+ states, reachable = [], False
129
+ if not reachable:
130
+ with self._lock:
131
+ for key in self._states:
132
+ if key[0] == host.name:
133
+ seen.add(key)
134
+ continue
135
+ for st in states:
136
+ st.source = host.name # always display under the host it came from
137
+ key = (host.name, st.vendor, st.session_id)
138
+ seen.add(key)
139
+ if not st.summary:
140
+ prev = self._states.get(key)
141
+ if prev and prev.summary and prev.last_activity == st.last_activity:
142
+ st.summary = prev.summary
143
+ else:
144
+ cached = self.summarizer.cached(st)
145
+ if cached:
146
+ st.summary = cached
147
+ with self._lock:
148
+ self._states[key] = st
149
+ self._mtimes[key] = st.last_activity or 0.0
150
+
151
+ def sessions(self) -> list[SessionState]:
152
+ with self._lock:
153
+ items = list(self._states.values())
154
+ items.sort(
155
+ key=lambda s: (
156
+ _STATE_ORDER.get(s.state, 9),
157
+ -(s.last_activity or 0.0),
158
+ )
159
+ )
160
+ return items
161
+
162
+ def counts(self) -> dict:
163
+ with self._lock:
164
+ items = list(self._states.values())
165
+ out = {s.value: 0 for s in State}
166
+ for st in items:
167
+ out[st.state.value] += 1
168
+ out["total"] = len(items)
169
+ return out
170
+
171
+ def request_summary(self, key, force: bool = True) -> None:
172
+ key = tuple(key)
173
+ with self._lock:
174
+ st = self._states.get(key)
175
+ if st is None:
176
+ # Stored entries are keyed by the discover-time id, but a caller
177
+ # (the TUI) passes the session's canonical key — adapters such as
178
+ # Codex/Gemini rewrite session_id from file contents. Fall back to
179
+ # matching on the state's own key so manual summaries never no-op.
180
+ for s in self._states.values():
181
+ if s.key == key:
182
+ st = s
183
+ break
184
+ if st is not None:
185
+ self.summarizer.request(st, force=force)
186
+
187
+ def summarize_all(self, force: bool = False) -> int:
188
+ """Full sweep: request a plain-language summary for every current
189
+ session. Cached summaries are reused unless ``force``. Returns the
190
+ number of sessions swept (0 when the model is disabled)."""
191
+ if not self.summarizer.enabled:
192
+ return 0
193
+ sessions = self.sessions()
194
+ for st in sessions:
195
+ self.summarizer.request(st, force=force)
196
+ return len(sessions)
197
+
198
+ # --- internals ---
199
+
200
+ def _reage(self, st: SessionState, now: float) -> None:
201
+ """Promote a quiet IDLE session to DONE once it crosses DONE_AFTER,
202
+ without re-reading the file."""
203
+ if (
204
+ st.state == State.IDLE
205
+ and st.last_activity is not None
206
+ and (now - st.last_activity) >= config.DONE_AFTER
207
+ ):
208
+ st.state = State.DONE
209
+
210
+ def _evict(self, seen: set, now: float) -> None:
211
+ with self._lock:
212
+ for key in list(self._states):
213
+ st = self._states[key]
214
+ gone = key not in seen
215
+ too_old = (
216
+ st.last_activity is not None
217
+ and (now - st.last_activity) > config.MAX_AGE
218
+ )
219
+ if gone or too_old:
220
+ self._states.pop(key, None)
221
+ self._mtimes.pop(key, None)
222
+
223
+ def _dispatch_summaries(self) -> None:
224
+ if not self.summarizer.enabled:
225
+ return
226
+ for st in self.sessions():
227
+ if st.needs_attention and not st.summary:
228
+ self.summarizer.request(st)
229
+
230
+ @staticmethod
231
+ def _error_state(vendor: str, ref, msg: str) -> SessionState:
232
+ return SessionState(
233
+ vendor=vendor,
234
+ session_id=ref.session_id,
235
+ project=(ref.cwd or "?").rstrip("/").split("/")[-1] or "?",
236
+ cwd=ref.cwd,
237
+ state=State.ERROR,
238
+ doing="could not read session",
239
+ needs="unreadable transcript",
240
+ error=msg[:200],
241
+ )
fleetwatch/models.py ADDED
@@ -0,0 +1,110 @@
1
+ """The data contract shared by every adapter, the aggregator, and the UI.
2
+
3
+ A ``SessionState`` is a normalized snapshot of one CLI coding session, no matter
4
+ which vendor produced it. Adapters translate vendor-specific files into this
5
+ shape; the aggregator collects them; the TUI renders them. It stays fully
6
+ JSON-serializable on purpose: a remote host can export the exact same records
7
+ over the wire, which is how VPS support drops in later without reworking this.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from typing import Optional
15
+
16
+
17
+ class State(str, Enum):
18
+ """Lifecycle of a session, in rough order of how much it wants you."""
19
+
20
+ ACTIVE = "active" # transcript changed very recently; the agent is working
21
+ WAITING = "waiting" # blocked on the human (permission prompt, a question)
22
+ IDLE = "idle" # last turn finished recently; likely still open
23
+ DONE = "done" # last turn finished long ago; effectively over
24
+ ERROR = "error" # vendor error, or the transcript could not be read
25
+
26
+ def __str__(self) -> str: # so f-strings print "active" not "State.ACTIVE"
27
+ return self.value
28
+
29
+
30
+ @dataclass
31
+ class TodoItem:
32
+ text: str
33
+ status: str = "pending" # pending | in_progress | completed
34
+
35
+
36
+ @dataclass
37
+ class SessionState:
38
+ # --- identity ---
39
+ vendor: str # "claude" | "codex" | "grok" | "gemini"
40
+ session_id: str
41
+ project: str # human label, usually the basename of cwd
42
+ cwd: Optional[str] = None # full working directory when known
43
+ source: str = "local" # host label; "local" now, a VPS hostname later
44
+
45
+ # --- liveness ---
46
+ last_activity: Optional[float] = None # epoch seconds (file mtime / last ts)
47
+ state: State = State.IDLE
48
+
49
+ # --- what it's doing ---
50
+ doing: str = "" # heuristic one-liner the adapter fills in
51
+ needs: Optional[str] = None # human-facing reason it wants attention
52
+ summary: Optional[str] = None # model-written paragraph, filled in lazily
53
+
54
+ # --- context for the detail pane ---
55
+ todos: list[TodoItem] = field(default_factory=list)
56
+ last_user: str = ""
57
+ last_agent: str = ""
58
+
59
+ # --- bookkeeping ---
60
+ error: Optional[str] = None
61
+
62
+ @property
63
+ def key(self) -> tuple[str, str, str]:
64
+ """Stable identity across refreshes and across hosts."""
65
+ return (self.source, self.vendor, self.session_id)
66
+
67
+ @property
68
+ def needs_attention(self) -> bool:
69
+ return self.state in (State.WAITING, State.ERROR)
70
+
71
+ def to_dict(self) -> dict:
72
+ return {
73
+ "vendor": self.vendor,
74
+ "session_id": self.session_id,
75
+ "project": self.project,
76
+ "cwd": self.cwd,
77
+ "source": self.source,
78
+ "last_activity": self.last_activity,
79
+ "state": self.state.value,
80
+ "doing": self.doing,
81
+ "needs": self.needs,
82
+ "summary": self.summary,
83
+ "todos": [{"text": t.text, "status": t.status} for t in self.todos],
84
+ "last_user": self.last_user,
85
+ "last_agent": self.last_agent,
86
+ "error": self.error,
87
+ }
88
+
89
+ @classmethod
90
+ def from_dict(cls, d: dict) -> "SessionState":
91
+ return cls(
92
+ vendor=d["vendor"],
93
+ session_id=d["session_id"],
94
+ project=d["project"],
95
+ cwd=d.get("cwd"),
96
+ source=d.get("source", "local"),
97
+ last_activity=d.get("last_activity"),
98
+ state=State(d.get("state", "idle")),
99
+ doing=d.get("doing", ""),
100
+ needs=d.get("needs"),
101
+ summary=d.get("summary"),
102
+ todos=[
103
+ TodoItem(text=str(t.get("text", "")), status=t.get("status", "pending"))
104
+ for t in d.get("todos", [])
105
+ if isinstance(t, dict) and t.get("text")
106
+ ],
107
+ last_user=d.get("last_user", ""),
108
+ last_agent=d.get("last_agent", ""),
109
+ error=d.get("error"),
110
+ )
fleetwatch/palette.py ADDED
@@ -0,0 +1,107 @@
1
+ """The one place fleetwatch decides what color anything is.
2
+
3
+ Two renderers consume these colors — the Textual dashboard (:mod:`tui`), which
4
+ speaks Rich style strings, and the plain-text snapshot (:mod:`render`), which
5
+ speaks ANSI for ``--once`` on a terminal. Keeping both mappings here means the
6
+ live board and the text view never disagree about what "waiting" looks like.
7
+
8
+ The scheme is built for color-blind readers: state is carried by luminance and
9
+ hue *position*, never red-vs-green alone. ``active`` is cyan (not green) so it
10
+ never collides with ``error``'s red on a deuteranope's screen, and ``idle`` /
11
+ ``done`` recede into grey. Vendor accents live in a separate hue family
12
+ (orange, green, magenta, blue) so a vendor tag is never read as a status.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .models import State
18
+
19
+ # Bright primaries for the three states that carry signal — blue is "live",
20
+ # yellow is "your move", red is "broken". WAITING and ERROR are bold so they
21
+ # jump out of a full table. IDLE and DONE recede into grey. No state leans on a
22
+ # red-vs-green distinction, the one pair color-blind readers can't separate.
23
+ STATE_STYLE: dict[State, str] = {
24
+ State.ACTIVE: "bright_blue",
25
+ State.WAITING: "bold bright_yellow",
26
+ State.ERROR: "bold bright_red",
27
+ State.IDLE: "grey58",
28
+ State.DONE: "grey42",
29
+ }
30
+
31
+ # A glyph per state: a second, color-free channel. Read by shape alone, the
32
+ # board still parses for a reader who can't see the hue — and ● / ○ / · also
33
+ # track "presence" (live, idle, faded) at a glance.
34
+ STATE_GLYPH: dict[State, str] = {
35
+ State.ACTIVE: "●",
36
+ State.WAITING: "◆",
37
+ State.ERROR: "✗",
38
+ State.IDLE: "○",
39
+ State.DONE: "·",
40
+ }
41
+
42
+ # Per-vendor accent: a hue family of its own (orange, cyan, magenta, violet),
43
+ # every one distinct from the state colors so a vendor tag never reads as a
44
+ # status. No green here either.
45
+ VENDOR_STYLE: dict[str, str] = {
46
+ "claude": "dark_orange",
47
+ "codex": "bright_cyan",
48
+ "grok": "bright_magenta",
49
+ "gemini": "medium_purple1",
50
+ }
51
+
52
+ _DEFAULT = "white"
53
+
54
+
55
+ def state_style(state: State) -> str:
56
+ """Rich style for a session state; white for anything unrecognized."""
57
+ return STATE_STYLE.get(state, _DEFAULT)
58
+
59
+
60
+ def state_glyph(state: State) -> str:
61
+ """Single-character status mark for a state; a space if unrecognized."""
62
+ return STATE_GLYPH.get(state, " ")
63
+
64
+
65
+ def state_label(state: State) -> str:
66
+ """Glyph + name, e.g. ``"● active"`` — the state as it reads in a cell."""
67
+ return f"{state_glyph(state)} {state}"
68
+
69
+
70
+ def vendor_style(vendor: str) -> str:
71
+ """Rich style for a vendor accent; white for an unknown vendor."""
72
+ return VENDOR_STYLE.get((vendor or "").lower(), _DEFAULT)
73
+
74
+
75
+ # --- ANSI for the plain-text snapshot ------------------------------------- #
76
+ # SGR codes mirroring the Rich styles above, so `fleetwatch --once` on a TTY
77
+ # carries the same scheme. Unknown styles paint nothing (graceful no-op).
78
+ _ANSI: dict[str, str] = {
79
+ "cyan": "36",
80
+ "bright_blue": "94",
81
+ "bold bright_yellow": "1;93",
82
+ "bold bright_red": "1;91",
83
+ "grey58": "38;5;246",
84
+ "grey42": "38;5;240",
85
+ "dark_orange": "38;5;208",
86
+ "bright_cyan": "96",
87
+ "bright_magenta": "95",
88
+ "medium_purple1": "38;5;141",
89
+ "white": "37",
90
+ "dim": "2",
91
+ "bold": "1",
92
+ }
93
+
94
+
95
+ def paint(style: str, text: str, enabled: bool = True) -> str:
96
+ """Wrap ``text`` in the ANSI code for ``style`` when ``enabled``.
97
+
98
+ Returns ``text`` untouched when disabled or when the style has no ANSI
99
+ mapping, so callers can always route through here and let a non-TTY (or an
100
+ unknown style) degrade to plain text.
101
+ """
102
+ if not enabled:
103
+ return text
104
+ code = _ANSI.get(style)
105
+ if not code:
106
+ return text
107
+ return f"\x1b[{code}m{text}\x1b[0m"
fleetwatch/remote.py ADDED
@@ -0,0 +1,104 @@
1
+ """Watching other machines, the cheap and robust way.
2
+
3
+ Instead of tailing a remote host's session files over a long-lived connection,
4
+ fleetwatch asks the remote to do its own normalization and just hand back the
5
+ answer: one ``ssh <host> fleetwatch --export-json`` per refresh returns that
6
+ host's whole fleet as JSON. One command per host (not N file tails), the work
7
+ happens where the files are, and the conversation content never leaves the
8
+ channel you already trust.
9
+
10
+ A failed fetch does NOT drop the host's sessions — the aggregator keeps the last
11
+ snapshot so an unreachable host reads as "stale", never as "all gone".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import subprocess
18
+ from typing import Optional
19
+
20
+ from .models import SessionState
21
+
22
+
23
+ class RemoteHost:
24
+ """One machine reachable over ssh, watched by pulling its JSON export."""
25
+
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ ssh_target: Optional[str] = None,
30
+ command: str = "fleetwatch --export-json",
31
+ timeout: float = 15.0,
32
+ ):
33
+ self.name = name
34
+ self.ssh_target = ssh_target or name
35
+ self.command = command
36
+ self.timeout = timeout
37
+
38
+ def fetch(self) -> "tuple[list[SessionState], bool]":
39
+ """Return ``(sessions, reachable)``. Never raises.
40
+
41
+ ``reachable`` is False on any ssh/parse trouble; sessions is then empty
42
+ and the caller should keep whatever it already had for this host.
43
+ """
44
+ # Force heuristics-only on the remote so it never spends tokens; the
45
+ # local summarizer fills in plain-language summaries for the whole fleet
46
+ # from one API key.
47
+ remote_cmd = f"FLEETWATCH_NO_MODEL=1 {self.command}"
48
+ argv = [
49
+ "ssh",
50
+ "-o", "BatchMode=yes",
51
+ "-o", f"ConnectTimeout={int(self.timeout)}",
52
+ self.ssh_target,
53
+ remote_cmd,
54
+ ]
55
+ try:
56
+ proc = subprocess.run(
57
+ argv,
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=self.timeout + 5,
61
+ )
62
+ except (OSError, subprocess.TimeoutExpired):
63
+ return [], False
64
+ if proc.returncode != 0 or not proc.stdout.strip():
65
+ return [], False
66
+ try:
67
+ data = json.loads(proc.stdout)
68
+ except (ValueError, TypeError):
69
+ return [], False
70
+ if not isinstance(data, list):
71
+ return [], False
72
+
73
+ out: list[SessionState] = []
74
+ for d in data:
75
+ try:
76
+ st = SessionState.from_dict(d)
77
+ except Exception:
78
+ continue
79
+ st.source = self.name # relabel: these sessions live on this host
80
+ out.append(st)
81
+ return out, True
82
+
83
+
84
+ def parse_hosts(spec: str) -> list[RemoteHost]:
85
+ """Parse a ``--hosts`` / ``FLEETWATCH_HOSTS`` string into RemoteHosts.
86
+
87
+ Comma-separated. Each entry is ``name`` or ``name=ssh_target``. The pseudo
88
+ host ``local`` is ignored here (the aggregator always watches local).
89
+
90
+ "dreamer" -> ssh dreamer
91
+ "dreamer=luke@dr.eamer.dev" -> ssh luke@dr.eamer.dev (labelled dreamer)
92
+ "local,dreamer,box=user@host" -> dreamer + box
93
+ """
94
+ hosts: list[RemoteHost] = []
95
+ for raw in (spec or "").split(","):
96
+ entry = raw.strip()
97
+ if not entry or entry.lower() == "local":
98
+ continue
99
+ if "=" in entry:
100
+ name, target = entry.split("=", 1)
101
+ hosts.append(RemoteHost(name.strip(), target.strip()))
102
+ else:
103
+ hosts.append(RemoteHost(entry))
104
+ return hosts