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/__init__.py +3 -0
- fleetwatch/adapters/__init__.py +36 -0
- fleetwatch/adapters/base.py +112 -0
- fleetwatch/adapters/claude.py +425 -0
- fleetwatch/adapters/codex.py +411 -0
- fleetwatch/adapters/gemini.py +377 -0
- fleetwatch/adapters/grok.py +491 -0
- fleetwatch/cli.py +83 -0
- fleetwatch/config.py +43 -0
- fleetwatch/core.py +241 -0
- fleetwatch/models.py +110 -0
- fleetwatch/palette.py +107 -0
- fleetwatch/remote.py +104 -0
- fleetwatch/render.py +157 -0
- fleetwatch/summarize.py +141 -0
- fleetwatch/tailer.py +63 -0
- fleetwatch/tui.py +475 -0
- fleetwatch/util.py +28 -0
- fleetwatcher-0.4.0.dist-info/METADATA +200 -0
- fleetwatcher-0.4.0.dist-info/RECORD +24 -0
- fleetwatcher-0.4.0.dist-info/WHEEL +5 -0
- fleetwatcher-0.4.0.dist-info/entry_points.txt +3 -0
- fleetwatcher-0.4.0.dist-info/licenses/LICENSE +21 -0
- fleetwatcher-0.4.0.dist-info/top_level.txt +1 -0
fleetwatch/render.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Plain-text snapshot rendering for ``fleetwatch --once``.
|
|
2
|
+
|
|
3
|
+
This module is deliberately dependency-light: it produces a single multi-line
|
|
4
|
+
string from a list of :class:`~fleetwatch.models.SessionState`. The TUI uses
|
|
5
|
+
Textual, but ``--once`` (and any pipe-to-a-log usage) only needs text, so this
|
|
6
|
+
stays importable and runnable with nothing but the standard library.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Iterable, Optional
|
|
13
|
+
|
|
14
|
+
from .models import SessionState, State
|
|
15
|
+
from .palette import paint, state_glyph, state_style, vendor_style
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Order counts appear in the header line, matching the lifecycle in State.
|
|
19
|
+
_COUNT_ORDER = ("active", "waiting", "idle", "done", "error")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def humanize_age(last_activity: Optional[float], now: Optional[float] = None) -> str:
|
|
23
|
+
"""Render a seconds-since-epoch timestamp as a compact age (e.g. "5m").
|
|
24
|
+
|
|
25
|
+
``None`` -> "-". Buckets: <60s -> Ns, <1h -> Nm, <1d -> Nh, else Nd.
|
|
26
|
+
"""
|
|
27
|
+
if last_activity is None:
|
|
28
|
+
return "-"
|
|
29
|
+
now = time.time() if now is None else now
|
|
30
|
+
delta = now - last_activity
|
|
31
|
+
if delta < 0:
|
|
32
|
+
delta = 0
|
|
33
|
+
if delta < 60:
|
|
34
|
+
return f"{int(delta)}s"
|
|
35
|
+
if delta < 3600:
|
|
36
|
+
return f"{int(delta // 60)}m"
|
|
37
|
+
if delta < 86400:
|
|
38
|
+
return f"{int(delta // 3600)}h"
|
|
39
|
+
return f"{int(delta // 86400)}d"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _truncate(text: str, width: int) -> str:
|
|
43
|
+
text = (text or "").replace("\n", " ").strip()
|
|
44
|
+
if len(text) <= width:
|
|
45
|
+
return text
|
|
46
|
+
if width <= 1:
|
|
47
|
+
return text[:width]
|
|
48
|
+
return text[: width - 1] + "…" # ellipsis
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _header(counts: Optional[dict], now: float, color: bool = False) -> list[str]:
|
|
52
|
+
clock = time.strftime("%H:%M:%S", time.localtime(now))
|
|
53
|
+
head = paint("cyan", "fleetwatch", color)
|
|
54
|
+
clock_s = paint("grey58", clock, color)
|
|
55
|
+
if not counts:
|
|
56
|
+
return [f"{head} {clock_s}"]
|
|
57
|
+
parts = []
|
|
58
|
+
for name in _COUNT_ORDER:
|
|
59
|
+
if name in counts:
|
|
60
|
+
chunk = f"{name} {counts[name]}"
|
|
61
|
+
if name in State._value2member_map_:
|
|
62
|
+
chunk = paint(state_style(State(name)), chunk, color)
|
|
63
|
+
parts.append(chunk)
|
|
64
|
+
total = counts.get("total")
|
|
65
|
+
summary = " ".join(parts)
|
|
66
|
+
if total is not None:
|
|
67
|
+
total_s = paint("bold", f"total {total}", color)
|
|
68
|
+
summary = f"{summary} ({total_s})" if summary else total_s
|
|
69
|
+
return [f"{head} {clock_s} {summary}".rstrip()]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_snapshot(
|
|
73
|
+
sessions: Iterable[SessionState],
|
|
74
|
+
counts: Optional[dict] = None,
|
|
75
|
+
now: Optional[float] = None,
|
|
76
|
+
color: bool = False,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Return a clean multi-line text table for ``fleetwatch --once``.
|
|
79
|
+
|
|
80
|
+
One line per session: vendor, project, state, humanized idle age, a "!"
|
|
81
|
+
when the session needs attention, and the doing/needs text. A header with
|
|
82
|
+
counts is included when ``counts`` is provided. An empty list renders a
|
|
83
|
+
friendly placeholder.
|
|
84
|
+
|
|
85
|
+
With ``color=True`` the same state/vendor palette as the dashboard is
|
|
86
|
+
written as ANSI; the CLI turns it on only when stdout is a TTY, so piping
|
|
87
|
+
``--once`` to a file or a log stays plain text. Column widths are computed
|
|
88
|
+
on the uncolored text, so alignment holds whether or not color is on.
|
|
89
|
+
"""
|
|
90
|
+
sessions = list(sessions)
|
|
91
|
+
now = time.time() if now is None else now
|
|
92
|
+
|
|
93
|
+
lines = _header(counts, now, color)
|
|
94
|
+
|
|
95
|
+
if not sessions:
|
|
96
|
+
lines.append("")
|
|
97
|
+
lines.append("No active sessions.")
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
# Only show a host column when watching more than one source, so the
|
|
101
|
+
# single-machine view stays uncluttered.
|
|
102
|
+
sources = {s.source for s in sessions}
|
|
103
|
+
show_host = len(sources) > 1
|
|
104
|
+
|
|
105
|
+
# Column widths sized to content, with sane caps.
|
|
106
|
+
host_w = min(10, max(4, *(len(s.source) for s in sessions))) if show_host else 0
|
|
107
|
+
vendor_w = max(6, *(len(s.vendor) for s in sessions))
|
|
108
|
+
project_w = min(24, max(7, *(len(s.project) for s in sessions)))
|
|
109
|
+
state_w = max(len(str(s.state)) for s in sessions)
|
|
110
|
+
state_w = max(state_w, 5)
|
|
111
|
+
|
|
112
|
+
host_head = f"{'HOST':<{host_w}} " if show_host else ""
|
|
113
|
+
header_row = (
|
|
114
|
+
f"{host_head}"
|
|
115
|
+
f"{'VENDOR':<{vendor_w}} "
|
|
116
|
+
f"{'PROJECT':<{project_w}} "
|
|
117
|
+
f" {'STATE':<{state_w}} " # two-space gutter under the status glyph
|
|
118
|
+
f"{'IDLE':>4} "
|
|
119
|
+
f"{'!':<1} "
|
|
120
|
+
f"WHAT"
|
|
121
|
+
)
|
|
122
|
+
lines.append("")
|
|
123
|
+
lines.append(header_row)
|
|
124
|
+
lines.append("─" * len(header_row))
|
|
125
|
+
|
|
126
|
+
for s in sessions:
|
|
127
|
+
age = humanize_age(s.last_activity, now)
|
|
128
|
+
bang = "!" if s.needs_attention else " "
|
|
129
|
+
# Prefer the human-facing "needs" reason when attention is wanted,
|
|
130
|
+
# otherwise the adapter's "doing" one-liner.
|
|
131
|
+
what = s.needs if (s.needs_attention and s.needs) else s.doing
|
|
132
|
+
|
|
133
|
+
# Pad to width first, then paint, so the ANSI bytes never throw the
|
|
134
|
+
# column alignment off.
|
|
135
|
+
vendor_c = paint(vendor_style(s.vendor),
|
|
136
|
+
f"{_truncate(s.vendor, vendor_w):<{vendor_w}}", color)
|
|
137
|
+
state_c = paint(state_style(s.state),
|
|
138
|
+
f"{state_glyph(s.state)} {str(s.state):<{state_w}}", color)
|
|
139
|
+
age_c = paint("grey58", f"{age:>4}", color)
|
|
140
|
+
bang_c = (paint("bold bright_red", f"{bang:<1}", color)
|
|
141
|
+
if s.needs_attention else f"{bang:<1}")
|
|
142
|
+
host_cell = ""
|
|
143
|
+
if show_host:
|
|
144
|
+
host_cell = paint("grey58",
|
|
145
|
+
f"{_truncate(s.source, host_w):<{host_w}}", color) + " "
|
|
146
|
+
|
|
147
|
+
lines.append(
|
|
148
|
+
f"{host_cell}"
|
|
149
|
+
f"{vendor_c} "
|
|
150
|
+
f"{_truncate(s.project, project_w):<{project_w}} "
|
|
151
|
+
f"{state_c} "
|
|
152
|
+
f"{age_c} "
|
|
153
|
+
f"{bang_c} "
|
|
154
|
+
f"{_truncate(what, 60)}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return "\n".join(lines)
|
fleetwatch/summarize.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Hybrid plain-language summaries.
|
|
2
|
+
|
|
3
|
+
Every session already carries a free, instant heuristic one-liner (``doing``,
|
|
4
|
+
set by its adapter). On top of that, this module asks a fast model (Haiku) for a
|
|
5
|
+
single plain-language sentence — but only for sessions that need attention, and
|
|
6
|
+
only once per distinct activity timestamp. Results are cached and written back
|
|
7
|
+
onto the SessionState in place, off the UI thread, so the dashboard never blocks
|
|
8
|
+
on the network and idle fleets cost nothing.
|
|
9
|
+
|
|
10
|
+
If there is no API key, or the model is disabled, this degrades silently to the
|
|
11
|
+
heuristic line (``summary`` simply stays ``None`` and the UI falls back to
|
|
12
|
+
``doing``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from . import config
|
|
24
|
+
from .models import SessionState
|
|
25
|
+
|
|
26
|
+
_SYSTEM = (
|
|
27
|
+
"You report the status of a developer's terminal coding session in ONE "
|
|
28
|
+
"plain-language sentence, 25 words max. Say what it is doing and, if it is "
|
|
29
|
+
"blocked, exactly what it needs from the user. No preamble, no markdown, no "
|
|
30
|
+
"quotes — just the sentence."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Summarizer:
|
|
35
|
+
"""Background, cached, best-effort model summaries."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, model: Optional[str] = None, use_model: Optional[bool] = None):
|
|
38
|
+
self.model = model or config.SUMMARY_MODEL
|
|
39
|
+
use = config.USE_MODEL if use_model is None else use_model
|
|
40
|
+
self._client = None
|
|
41
|
+
self._pool: Optional[ThreadPoolExecutor] = None
|
|
42
|
+
self._cache: dict = {} # (key, last_activity) -> sentence
|
|
43
|
+
self._inflight: set = set()
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
if use and os.environ.get("ANTHROPIC_API_KEY"):
|
|
47
|
+
try:
|
|
48
|
+
import anthropic
|
|
49
|
+
|
|
50
|
+
self._client = anthropic.Anthropic()
|
|
51
|
+
self._pool = ThreadPoolExecutor(
|
|
52
|
+
max_workers=2, thread_name_prefix="fw-summary"
|
|
53
|
+
)
|
|
54
|
+
except Exception:
|
|
55
|
+
self._client = None # any import/auth trouble → heuristics only
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def enabled(self) -> bool:
|
|
59
|
+
return self._client is not None
|
|
60
|
+
|
|
61
|
+
def cached(self, st: SessionState) -> Optional[str]:
|
|
62
|
+
"""Return a cached sentence for this session's current activity, if any."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
return self._cache.get((st.key, st.last_activity))
|
|
65
|
+
|
|
66
|
+
def request(self, st: SessionState, force: bool = False) -> None:
|
|
67
|
+
"""Ensure a summary exists for ``st``, computing it in the background.
|
|
68
|
+
|
|
69
|
+
Applies a cached result immediately when available; otherwise schedules
|
|
70
|
+
one model call (deduplicated per activity timestamp unless ``force``).
|
|
71
|
+
"""
|
|
72
|
+
if not self.enabled:
|
|
73
|
+
return
|
|
74
|
+
ckey = (st.key, st.last_activity)
|
|
75
|
+
with self._lock:
|
|
76
|
+
if ckey in self._cache:
|
|
77
|
+
st.summary = self._cache[ckey]
|
|
78
|
+
if not force:
|
|
79
|
+
return
|
|
80
|
+
if ckey in self._inflight:
|
|
81
|
+
return
|
|
82
|
+
if self._pool is None: # enabled implies a pool, but never assert in a thread
|
|
83
|
+
return
|
|
84
|
+
self._inflight.add(ckey)
|
|
85
|
+
self._pool.submit(self._run, st, ckey)
|
|
86
|
+
|
|
87
|
+
def drain(self, timeout: float = 6.0) -> None:
|
|
88
|
+
"""Block until in-flight summaries finish (or timeout). Used by --once."""
|
|
89
|
+
if not self.enabled:
|
|
90
|
+
return
|
|
91
|
+
end = time.time() + timeout
|
|
92
|
+
while time.time() < end:
|
|
93
|
+
with self._lock:
|
|
94
|
+
if not self._inflight:
|
|
95
|
+
return
|
|
96
|
+
time.sleep(0.1)
|
|
97
|
+
|
|
98
|
+
# --- internals ---
|
|
99
|
+
|
|
100
|
+
def _run(self, st: SessionState, ckey) -> None:
|
|
101
|
+
text = None
|
|
102
|
+
try:
|
|
103
|
+
text = self._call_model(st)
|
|
104
|
+
except Exception:
|
|
105
|
+
text = None # network/model failure → leave heuristic in place
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._inflight.discard(ckey)
|
|
108
|
+
if text:
|
|
109
|
+
self._cache[ckey] = text
|
|
110
|
+
if text:
|
|
111
|
+
st.summary = text
|
|
112
|
+
|
|
113
|
+
def _call_model(self, st: SessionState) -> Optional[str]:
|
|
114
|
+
msg = self._client.messages.create( # type: ignore[union-attr]
|
|
115
|
+
model=self.model,
|
|
116
|
+
max_tokens=120,
|
|
117
|
+
system=_SYSTEM,
|
|
118
|
+
messages=[{"role": "user", "content": self._build_prompt(st)}],
|
|
119
|
+
)
|
|
120
|
+
parts = [
|
|
121
|
+
b.text for b in msg.content if getattr(b, "type", None) == "text"
|
|
122
|
+
]
|
|
123
|
+
# Collapse to a single clean line: a model that returns multiple text
|
|
124
|
+
# blocks or internal newlines must never read as concatenated summaries.
|
|
125
|
+
out = " ".join(" ".join(parts).split()).strip()
|
|
126
|
+
if len(out) > 240:
|
|
127
|
+
out = out[:239].rstrip() + "…"
|
|
128
|
+
return out or None
|
|
129
|
+
|
|
130
|
+
def _build_prompt(self, st: SessionState) -> str:
|
|
131
|
+
todos = "\n".join(f"- [{t.status}] {t.text}" for t in st.todos[:8])
|
|
132
|
+
return (
|
|
133
|
+
f"Vendor: {st.vendor}\n"
|
|
134
|
+
f"Project: {st.project}\n"
|
|
135
|
+
f"State: {st.state}\n"
|
|
136
|
+
f"Needs: {st.needs or '-'}\n"
|
|
137
|
+
f"Heuristic activity: {st.doing or '-'}\n"
|
|
138
|
+
f"Most recent user message: {st.last_user[:400] or '-'}\n"
|
|
139
|
+
f"Most recent agent message: {st.last_agent[:400] or '-'}\n"
|
|
140
|
+
f"Plan / todos:\n{todos or '-'}"
|
|
141
|
+
)
|
fleetwatch/tailer.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Bounded, crash-proof reads of append-only JSONL transcripts.
|
|
2
|
+
|
|
3
|
+
Session transcripts can be tens of megabytes (one on this machine is 25 MB) and
|
|
4
|
+
are written while we read them, so the final line is frequently a half-finished
|
|
5
|
+
write. Everything here reads only a bounded tail and never raises on a partial
|
|
6
|
+
final line or a missing file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DEFAULT_TAIL_BYTES = 512_000 # ~0.5 MB of tail captures plenty of recent activity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_tail_lines(path: str, max_bytes: int = DEFAULT_TAIL_BYTES) -> list[str]:
|
|
19
|
+
"""Return complete, non-empty text lines from the last ``max_bytes`` of a file.
|
|
20
|
+
|
|
21
|
+
If we did not start at byte 0, the first (possibly partial) line is dropped.
|
|
22
|
+
Returns ``[]`` for a missing, empty, or unreadable file instead of raising.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
size = os.path.getsize(path)
|
|
26
|
+
except OSError:
|
|
27
|
+
return []
|
|
28
|
+
if size == 0:
|
|
29
|
+
return []
|
|
30
|
+
start = max(0, size - max_bytes)
|
|
31
|
+
try:
|
|
32
|
+
with open(path, "rb") as fh:
|
|
33
|
+
fh.seek(start)
|
|
34
|
+
chunk = fh.read()
|
|
35
|
+
except OSError:
|
|
36
|
+
return []
|
|
37
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
38
|
+
lines = text.split("\n")
|
|
39
|
+
if start > 0 and lines:
|
|
40
|
+
lines = lines[1:] # the leading line is truncated; drop it
|
|
41
|
+
return [ln for ln in lines if ln.strip()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_jsonl_lines(lines: list[str]) -> list[dict[str, Any]]:
|
|
45
|
+
"""Parse lines into dicts, silently skipping any that do not parse.
|
|
46
|
+
|
|
47
|
+
The last line is often a partial live write, so unparseable lines are
|
|
48
|
+
expected and ignored rather than treated as errors.
|
|
49
|
+
"""
|
|
50
|
+
out: list[dict[str, Any]] = []
|
|
51
|
+
for ln in lines:
|
|
52
|
+
try:
|
|
53
|
+
obj = json.loads(ln)
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(obj, dict):
|
|
57
|
+
out.append(obj)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_tail_records(path: str, max_bytes: int = DEFAULT_TAIL_BYTES) -> list[dict[str, Any]]:
|
|
62
|
+
"""Convenience: tail a file and parse its JSONL lines in one step."""
|
|
63
|
+
return parse_jsonl_lines(read_tail_lines(path, max_bytes))
|