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
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Grok CLI adapter.
|
|
2
|
+
|
|
3
|
+
Grok (the xAI CLI) keeps everything under ``~/.grok``. The pieces this adapter
|
|
4
|
+
reads, and what each one is good for:
|
|
5
|
+
|
|
6
|
+
``~/.grok/active_sessions.json``
|
|
7
|
+
A JSON *list* of currently-running session objects. On this machine it is
|
|
8
|
+
almost always ``[]`` — Grok seems to clear it the moment a session exits —
|
|
9
|
+
so it is treated as pure enrichment, never as the source of truth for which
|
|
10
|
+
sessions exist or whether they are live.
|
|
11
|
+
|
|
12
|
+
``~/.grok/sessions/<url-encoded-cwd>/prompt_history.jsonl``
|
|
13
|
+
One file per working directory. The directory name is the cwd with ``/``
|
|
14
|
+
written as ``%2F`` (URL-encoded), so ``urllib.parse.unquote`` recovers the
|
|
15
|
+
real path. Each line is ``{timestamp, session_id, prompt, is_bash}``. This
|
|
16
|
+
is the spine of discovery and the most reliable recency signal we have: its
|
|
17
|
+
mtime tracks the human typing, and its tail gives us the last user prompt
|
|
18
|
+
and the session id.
|
|
19
|
+
|
|
20
|
+
``~/.grok/sessions/<encoded-cwd>/<session-id>/events.jsonl``
|
|
21
|
+
The structured per-session event log (``{ts, type, phase, tool_name,
|
|
22
|
+
decision, outcome}``). The tail of this file is where WAITING lives: a
|
|
23
|
+
``permission_requested`` with no following ``permission_resolved``, or a last
|
|
24
|
+
``phase_changed`` of ``permission_prompt``, means Grok is blocked on a human
|
|
25
|
+
approving a tool call. A ``turn_ended`` with ``outcome == "completed"`` means
|
|
26
|
+
the agent finished its turn.
|
|
27
|
+
|
|
28
|
+
``~/.grok/sessions/<encoded-cwd>/<session-id>/summary.json``
|
|
29
|
+
``{info:{id,cwd}, session_summary, last_active_at, agent_name}`` — a tidy
|
|
30
|
+
one-liner and a precise last-active timestamp used to enrich ``doing``.
|
|
31
|
+
|
|
32
|
+
``~/.grok/sessions/<encoded-cwd>/<session-id>/chat_history.jsonl``
|
|
33
|
+
The full conversation. User turns carry ``content`` as a list of
|
|
34
|
+
``{type:"text", text:...}``; assistant turns carry ``content`` as a string
|
|
35
|
+
plus ``tool_calls``. The latest ``todo_write`` tool call holds the plan as
|
|
36
|
+
``{id, content, status}`` items, which map straight onto ``TodoItem``.
|
|
37
|
+
|
|
38
|
+
Liveness rule of thumb: because ``active_sessions.json`` is unreliable, recency
|
|
39
|
+
is computed from the *newest* of the prompt_history / events / summary mtimes,
|
|
40
|
+
and ``active_sessions.json`` + ``events.jsonl`` only refine the state on top of
|
|
41
|
+
that.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import time
|
|
48
|
+
from typing import Optional
|
|
49
|
+
from urllib.parse import unquote
|
|
50
|
+
|
|
51
|
+
from ..config import ACTIVE_WINDOW, DONE_AFTER
|
|
52
|
+
from ..models import SessionState, State, TodoItem
|
|
53
|
+
from .base import Adapter, SessionRef, Source
|
|
54
|
+
|
|
55
|
+
GROK_HOME = "~/.grok"
|
|
56
|
+
SESSIONS_GLOB = "~/.grok/sessions/*/prompt_history.jsonl"
|
|
57
|
+
ACTIVE_SESSIONS = "~/.grok/active_sessions.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clip(text: str, limit: int = 280) -> str:
|
|
61
|
+
"""Collapse whitespace and clip to ``limit`` chars for the detail pane."""
|
|
62
|
+
text = " ".join((text or "").split())
|
|
63
|
+
if len(text) <= limit:
|
|
64
|
+
return text
|
|
65
|
+
return text[: limit - 1].rstrip() + "…"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _encoded_dir_name(path: str) -> str:
|
|
69
|
+
"""The encoded directory name (last path segment) of a prompt_history path."""
|
|
70
|
+
# path looks like .../sessions/<encoded-cwd>/prompt_history.jsonl
|
|
71
|
+
parts = path.replace("\\", "/").rstrip("/").split("/")
|
|
72
|
+
if len(parts) >= 2:
|
|
73
|
+
return parts[-2]
|
|
74
|
+
return parts[-1] if parts else ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _decode_cwd(encoded: str) -> str:
|
|
78
|
+
"""Recover a real cwd from a Grok-encoded directory name (``%2F`` -> ``/``)."""
|
|
79
|
+
return unquote(encoded)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _user_text(content) -> str:
|
|
83
|
+
"""Pull human-readable text out of a chat_history ``user`` content payload.
|
|
84
|
+
|
|
85
|
+
User content is a list of ``{type, text}`` blocks; assistant content is a
|
|
86
|
+
plain string. We also strip Grok's ``<user_query>`` wrapper when present so
|
|
87
|
+
the dashboard shows the actual prompt, not the XML scaffolding.
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(content, str):
|
|
90
|
+
text = content
|
|
91
|
+
elif isinstance(content, list):
|
|
92
|
+
chunks = []
|
|
93
|
+
for block in content:
|
|
94
|
+
if isinstance(block, dict):
|
|
95
|
+
t = block.get("text") or block.get("content") or ""
|
|
96
|
+
if isinstance(t, str):
|
|
97
|
+
chunks.append(t)
|
|
98
|
+
elif isinstance(block, str):
|
|
99
|
+
chunks.append(block)
|
|
100
|
+
text = " ".join(chunks)
|
|
101
|
+
else:
|
|
102
|
+
text = ""
|
|
103
|
+
# Unwrap <user_query>...</user_query> if it is the leading element.
|
|
104
|
+
if "<user_query>" in text and "</user_query>" in text:
|
|
105
|
+
start = text.index("<user_query>") + len("<user_query>")
|
|
106
|
+
end = text.index("</user_query>", start)
|
|
107
|
+
text = text[start:end]
|
|
108
|
+
return text
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _todos_from_tool_call(tc: dict) -> list[TodoItem]:
|
|
112
|
+
"""Turn a ``todo_write`` tool call into TodoItems, or [] if it isn't one."""
|
|
113
|
+
name = tc.get("name")
|
|
114
|
+
args = tc.get("arguments")
|
|
115
|
+
if name is None:
|
|
116
|
+
fn = tc.get("function") or {}
|
|
117
|
+
name = fn.get("name")
|
|
118
|
+
if args is None:
|
|
119
|
+
args = fn.get("arguments")
|
|
120
|
+
if name != "todo_write":
|
|
121
|
+
return []
|
|
122
|
+
if isinstance(args, str):
|
|
123
|
+
try:
|
|
124
|
+
args = json.loads(args)
|
|
125
|
+
except (ValueError, TypeError):
|
|
126
|
+
return []
|
|
127
|
+
if not isinstance(args, dict):
|
|
128
|
+
return []
|
|
129
|
+
out: list[TodoItem] = []
|
|
130
|
+
for item in args.get("todos", []) or []:
|
|
131
|
+
if not isinstance(item, dict):
|
|
132
|
+
continue
|
|
133
|
+
text = item.get("content") or item.get("text") or ""
|
|
134
|
+
status = item.get("status", "pending")
|
|
135
|
+
if text:
|
|
136
|
+
out.append(TodoItem(text=str(text), status=str(status)))
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class GrokAdapter(Adapter):
|
|
141
|
+
vendor = "grok"
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------ discover
|
|
144
|
+
def discover(self, source: Source) -> list[SessionRef]:
|
|
145
|
+
"""Union of (a) sessions named in ``active_sessions.json`` and (b) every
|
|
146
|
+
cwd directory under ``~/.grok/sessions`` holding a ``prompt_history.jsonl``.
|
|
147
|
+
Deduped by cwd. Returns ``[]`` when ``~/.grok`` is absent."""
|
|
148
|
+
if not source.exists(GROK_HOME):
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
# cwd -> SessionRef, so two views of the same directory collapse to one.
|
|
152
|
+
by_cwd: dict[str, SessionRef] = {}
|
|
153
|
+
|
|
154
|
+
# (a) enrichment registry: active_sessions.json (usually empty).
|
|
155
|
+
for entry in self._load_active_sessions(source):
|
|
156
|
+
cwd = entry.get("cwd") or entry.get("workdir") or entry.get("directory")
|
|
157
|
+
sid = (
|
|
158
|
+
entry.get("session_id")
|
|
159
|
+
or entry.get("id")
|
|
160
|
+
or entry.get("sessionId")
|
|
161
|
+
)
|
|
162
|
+
if not cwd:
|
|
163
|
+
continue
|
|
164
|
+
cwd = source.expand(str(cwd)) if str(cwd).startswith("~") else str(cwd)
|
|
165
|
+
encoded = self._encode_for_path(cwd)
|
|
166
|
+
history = f"{GROK_HOME}/sessions/{encoded}/prompt_history.jsonl"
|
|
167
|
+
by_cwd[cwd] = SessionRef(
|
|
168
|
+
path=history,
|
|
169
|
+
session_id=str(sid) if sid else self._stable_id(encoded),
|
|
170
|
+
cwd=cwd,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# (b) the durable truth: every prompt_history.jsonl on disk.
|
|
174
|
+
for history_path in source.glob(SESSIONS_GLOB):
|
|
175
|
+
encoded = _encoded_dir_name(history_path)
|
|
176
|
+
cwd = _decode_cwd(encoded)
|
|
177
|
+
if cwd in by_cwd:
|
|
178
|
+
# Keep the richer registry entry but make sure its path points at
|
|
179
|
+
# a file that actually exists.
|
|
180
|
+
if not source.exists(by_cwd[cwd].path):
|
|
181
|
+
by_cwd[cwd] = SessionRef(
|
|
182
|
+
path=history_path,
|
|
183
|
+
session_id=by_cwd[cwd].session_id,
|
|
184
|
+
cwd=cwd,
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
sid = self._latest_session_id(source, history_path) or self._stable_id(
|
|
188
|
+
encoded
|
|
189
|
+
)
|
|
190
|
+
by_cwd[cwd] = SessionRef(path=history_path, session_id=sid, cwd=cwd)
|
|
191
|
+
|
|
192
|
+
refs = list(by_cwd.values())
|
|
193
|
+
# Stamp each ref with a freshness hint that spans ALL of the session's
|
|
194
|
+
# files. Without this the aggregator keys on prompt_history.jsonl alone
|
|
195
|
+
# and misses an idle->waiting transition that only touches events.jsonl.
|
|
196
|
+
for r in refs:
|
|
197
|
+
r.mtime = self._freshness(source, r)
|
|
198
|
+
return refs
|
|
199
|
+
|
|
200
|
+
def _session_paths(self, cwd: str, session_id: str) -> list[str]:
|
|
201
|
+
"""The files that together make up one Grok session."""
|
|
202
|
+
encoded = self._encode_for_path(cwd)
|
|
203
|
+
base = f"{GROK_HOME}/sessions/{encoded}"
|
|
204
|
+
sdir = f"{base}/{session_id}"
|
|
205
|
+
return [
|
|
206
|
+
f"{base}/prompt_history.jsonl",
|
|
207
|
+
f"{sdir}/events.jsonl",
|
|
208
|
+
f"{sdir}/summary.json",
|
|
209
|
+
f"{sdir}/chat_history.jsonl",
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
def _freshness(self, source: Source, ref: SessionRef) -> float:
|
|
213
|
+
"""Newest mtime across all of a session's files (its real activity)."""
|
|
214
|
+
cwd = ref.cwd or _decode_cwd(_encoded_dir_name(ref.path))
|
|
215
|
+
newest = 0.0
|
|
216
|
+
for p in self._session_paths(cwd, ref.session_id):
|
|
217
|
+
if source.exists(p):
|
|
218
|
+
t = source.mtime(p)
|
|
219
|
+
if t > newest:
|
|
220
|
+
newest = t
|
|
221
|
+
return newest or source.mtime(ref.path)
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------- read
|
|
224
|
+
def read(
|
|
225
|
+
self, source: Source, ref: SessionRef, prev: Optional[SessionState]
|
|
226
|
+
) -> Optional[SessionState]:
|
|
227
|
+
"""Build the current SessionState for one Grok session. Never raises."""
|
|
228
|
+
try:
|
|
229
|
+
return self._read(source, ref, prev)
|
|
230
|
+
except Exception as exc: # contract: read() must not raise
|
|
231
|
+
cwd = ref.cwd
|
|
232
|
+
return SessionState(
|
|
233
|
+
vendor=self.vendor,
|
|
234
|
+
session_id=ref.session_id,
|
|
235
|
+
project=self._project(cwd),
|
|
236
|
+
cwd=cwd,
|
|
237
|
+
source=source.host,
|
|
238
|
+
last_activity=None,
|
|
239
|
+
state=State.ERROR,
|
|
240
|
+
error=f"grok read failed: {exc}",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _read(
|
|
244
|
+
self, source: Source, ref: SessionRef, prev: Optional[SessionState]
|
|
245
|
+
) -> Optional[SessionState]:
|
|
246
|
+
now = time.time()
|
|
247
|
+
cwd = ref.cwd or _decode_cwd(_encoded_dir_name(ref.path))
|
|
248
|
+
|
|
249
|
+
encoded = self._encode_for_path(cwd)
|
|
250
|
+
session_dir = f"{GROK_HOME}/sessions/{encoded}/{ref.session_id}"
|
|
251
|
+
events_path = f"{session_dir}/events.jsonl"
|
|
252
|
+
summary_path = f"{session_dir}/summary.json"
|
|
253
|
+
chat_path = f"{session_dir}/chat_history.jsonl"
|
|
254
|
+
|
|
255
|
+
# Recency is computed live (newest mtime across all of this session's
|
|
256
|
+
# files) so read() is always honest. The aggregator separately uses the
|
|
257
|
+
# discover()-stamped ref.mtime for its change-detection cache.
|
|
258
|
+
last_activity = self._freshness(source, ref)
|
|
259
|
+
recency = now - last_activity if last_activity else float("inf")
|
|
260
|
+
|
|
261
|
+
# --- last user prompt (from prompt_history; fall back to chat_history) ---
|
|
262
|
+
last_user = ""
|
|
263
|
+
history = source.tail_records(ref.path)
|
|
264
|
+
if history:
|
|
265
|
+
last_user = _clip(str(history[-1].get("prompt", "")))
|
|
266
|
+
|
|
267
|
+
# --- last agent message + todos (from chat_history) ---
|
|
268
|
+
last_agent = ""
|
|
269
|
+
todos: list[TodoItem] = []
|
|
270
|
+
doing = ""
|
|
271
|
+
if source.exists(chat_path):
|
|
272
|
+
for rec in source.tail_records(chat_path):
|
|
273
|
+
rtype = rec.get("type")
|
|
274
|
+
if rtype == "user":
|
|
275
|
+
txt = _clip(_user_text(rec.get("content")))
|
|
276
|
+
if txt:
|
|
277
|
+
last_user = txt
|
|
278
|
+
elif rtype == "assistant":
|
|
279
|
+
content = rec.get("content")
|
|
280
|
+
if isinstance(content, str) and content.strip():
|
|
281
|
+
last_agent = _clip(content)
|
|
282
|
+
for tc in rec.get("tool_calls") or []:
|
|
283
|
+
if isinstance(tc, dict):
|
|
284
|
+
found = _todos_from_tool_call(tc)
|
|
285
|
+
if found:
|
|
286
|
+
todos = found
|
|
287
|
+
|
|
288
|
+
# --- summary.json enriches `doing` ---
|
|
289
|
+
completed = False
|
|
290
|
+
if source.exists(summary_path):
|
|
291
|
+
try:
|
|
292
|
+
summ = json.loads(source.read_text(summary_path))
|
|
293
|
+
except (ValueError, TypeError):
|
|
294
|
+
summ = {}
|
|
295
|
+
if isinstance(summ, dict):
|
|
296
|
+
title = summ.get("session_summary") or summ.get("generated_title")
|
|
297
|
+
if title:
|
|
298
|
+
doing = _clip(str(title), 120)
|
|
299
|
+
|
|
300
|
+
# --- WAITING / completion signal from events.jsonl ---
|
|
301
|
+
waiting = False
|
|
302
|
+
needs: Optional[str] = None
|
|
303
|
+
events = source.tail_records(events_path) if source.exists(events_path) else []
|
|
304
|
+
if events:
|
|
305
|
+
waiting, needs, completed = self._scan_events(events)
|
|
306
|
+
|
|
307
|
+
# --- active_sessions.json enrichment (rare, but authoritative when set) ---
|
|
308
|
+
reg = self._registry_entry(source, cwd, ref.session_id)
|
|
309
|
+
if reg is not None:
|
|
310
|
+
if self._registry_says_waiting(reg):
|
|
311
|
+
waiting = True
|
|
312
|
+
if not needs:
|
|
313
|
+
needs = self._registry_need(reg) or "awaiting your input"
|
|
314
|
+
|
|
315
|
+
if not doing:
|
|
316
|
+
doing = last_user or last_agent or "grok session"
|
|
317
|
+
|
|
318
|
+
# --- state machine: activity wins -----------------------------------
|
|
319
|
+
if recency <= ACTIVE_WINDOW:
|
|
320
|
+
state = State.ACTIVE
|
|
321
|
+
elif waiting:
|
|
322
|
+
state = State.WAITING
|
|
323
|
+
if not needs:
|
|
324
|
+
needs = "awaiting your input"
|
|
325
|
+
elif completed and recency < DONE_AFTER:
|
|
326
|
+
state = State.IDLE
|
|
327
|
+
elif completed:
|
|
328
|
+
state = State.DONE
|
|
329
|
+
elif recency < DONE_AFTER:
|
|
330
|
+
# No explicit completion signal and not fresh: treat as recently idle.
|
|
331
|
+
state = State.IDLE
|
|
332
|
+
else:
|
|
333
|
+
state = State.DONE
|
|
334
|
+
|
|
335
|
+
return SessionState(
|
|
336
|
+
vendor=self.vendor,
|
|
337
|
+
session_id=ref.session_id,
|
|
338
|
+
project=self._project(cwd),
|
|
339
|
+
cwd=cwd,
|
|
340
|
+
source=source.host,
|
|
341
|
+
last_activity=last_activity or None,
|
|
342
|
+
state=state,
|
|
343
|
+
doing=doing,
|
|
344
|
+
needs=needs if state == State.WAITING else None,
|
|
345
|
+
summary=None,
|
|
346
|
+
todos=todos,
|
|
347
|
+
last_user=last_user,
|
|
348
|
+
last_agent=last_agent,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------ helpers
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _project(cwd: Optional[str]) -> str:
|
|
354
|
+
if not cwd:
|
|
355
|
+
return "grok"
|
|
356
|
+
base = cwd.replace("\\", "/").rstrip("/").split("/")[-1]
|
|
357
|
+
return base or cwd
|
|
358
|
+
|
|
359
|
+
@staticmethod
|
|
360
|
+
def _encode_for_path(cwd: str) -> str:
|
|
361
|
+
"""Re-encode a cwd the way Grok names its session directories.
|
|
362
|
+
|
|
363
|
+
Grok encodes ``/`` as ``%2F``; other characters are left as-is on the
|
|
364
|
+
machines observed, so a targeted replace round-trips ``_decode_cwd``.
|
|
365
|
+
"""
|
|
366
|
+
return cwd.replace("/", "%2F")
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _stable_id(encoded_dir: str) -> str:
|
|
370
|
+
"""A deterministic id when no real session id is available, derived from
|
|
371
|
+
the encoded cwd directory name so it stays constant across refreshes."""
|
|
372
|
+
return f"grok:{encoded_dir}"
|
|
373
|
+
|
|
374
|
+
def _load_active_sessions(self, source: Source) -> list[dict]:
|
|
375
|
+
if not source.exists(ACTIVE_SESSIONS):
|
|
376
|
+
return []
|
|
377
|
+
raw = source.read_text(ACTIVE_SESSIONS)
|
|
378
|
+
if not raw.strip():
|
|
379
|
+
return []
|
|
380
|
+
try:
|
|
381
|
+
data = json.loads(raw)
|
|
382
|
+
except (ValueError, TypeError):
|
|
383
|
+
return []
|
|
384
|
+
if isinstance(data, list):
|
|
385
|
+
return [d for d in data if isinstance(d, dict)]
|
|
386
|
+
if isinstance(data, dict):
|
|
387
|
+
# tolerate {"sessions": [...]} just in case the shape varies
|
|
388
|
+
inner = data.get("sessions")
|
|
389
|
+
if isinstance(inner, list):
|
|
390
|
+
return [d for d in inner if isinstance(d, dict)]
|
|
391
|
+
return [data]
|
|
392
|
+
return []
|
|
393
|
+
|
|
394
|
+
def _registry_entry(
|
|
395
|
+
self, source: Source, cwd: str, session_id: str
|
|
396
|
+
) -> Optional[dict]:
|
|
397
|
+
for entry in self._load_active_sessions(source):
|
|
398
|
+
ecwd = entry.get("cwd") or entry.get("workdir") or entry.get("directory")
|
|
399
|
+
esid = (
|
|
400
|
+
entry.get("session_id")
|
|
401
|
+
or entry.get("id")
|
|
402
|
+
or entry.get("sessionId")
|
|
403
|
+
)
|
|
404
|
+
if ecwd and str(ecwd).rstrip("/") == str(cwd).rstrip("/"):
|
|
405
|
+
return entry
|
|
406
|
+
if esid and str(esid) == str(session_id):
|
|
407
|
+
return entry
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def _registry_says_waiting(entry: dict) -> bool:
|
|
412
|
+
status = str(
|
|
413
|
+
entry.get("status") or entry.get("state") or entry.get("phase") or ""
|
|
414
|
+
).lower()
|
|
415
|
+
if any(
|
|
416
|
+
tok in status
|
|
417
|
+
for tok in ("wait", "await", "block", "permission", "approval", "prompt")
|
|
418
|
+
):
|
|
419
|
+
return True
|
|
420
|
+
for flag in (
|
|
421
|
+
"awaiting_input",
|
|
422
|
+
"awaiting_approval",
|
|
423
|
+
"needs_input",
|
|
424
|
+
"needs_approval",
|
|
425
|
+
"blocked",
|
|
426
|
+
"waiting_for_user",
|
|
427
|
+
):
|
|
428
|
+
if bool(entry.get(flag)):
|
|
429
|
+
return True
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _registry_need(entry: dict) -> Optional[str]:
|
|
434
|
+
for key in ("needs", "reason", "prompt", "question", "pending_tool"):
|
|
435
|
+
val = entry.get(key)
|
|
436
|
+
if val:
|
|
437
|
+
return str(val)
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
def _latest_session_id(
|
|
441
|
+
self, source: Source, history_path: str
|
|
442
|
+
) -> Optional[str]:
|
|
443
|
+
recs = source.tail_records(history_path)
|
|
444
|
+
for rec in reversed(recs):
|
|
445
|
+
sid = rec.get("session_id")
|
|
446
|
+
if sid:
|
|
447
|
+
return str(sid)
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def _scan_events(events: list[dict]) -> tuple[bool, Optional[str], bool]:
|
|
452
|
+
"""Inspect the tail of a per-session events.jsonl.
|
|
453
|
+
|
|
454
|
+
Returns ``(waiting, needs, completed)``.
|
|
455
|
+
|
|
456
|
+
WAITING is detected from a real Grok signal: either an unmatched
|
|
457
|
+
``permission_requested`` (a tool call awaiting human approval), or the
|
|
458
|
+
most recent ``phase_changed`` being ``permission_prompt``. ``completed``
|
|
459
|
+
is set when the last ``turn_ended`` reported ``outcome == "completed"``.
|
|
460
|
+
"""
|
|
461
|
+
waiting = False
|
|
462
|
+
needs: Optional[str] = None
|
|
463
|
+
completed = False
|
|
464
|
+
|
|
465
|
+
pending_permission: Optional[str] = None # tool_name awaiting a decision
|
|
466
|
+
last_phase: Optional[str] = None
|
|
467
|
+
last_turn_outcome: Optional[str] = None
|
|
468
|
+
|
|
469
|
+
for ev in events:
|
|
470
|
+
etype = ev.get("type")
|
|
471
|
+
if etype == "permission_requested":
|
|
472
|
+
pending_permission = ev.get("tool_name") or "a tool"
|
|
473
|
+
elif etype == "permission_resolved":
|
|
474
|
+
pending_permission = None # the human (or auto-allow) answered
|
|
475
|
+
elif etype == "phase_changed":
|
|
476
|
+
last_phase = ev.get("phase")
|
|
477
|
+
elif etype == "turn_ended":
|
|
478
|
+
last_turn_outcome = ev.get("outcome")
|
|
479
|
+
pending_permission = None # the turn is over; nothing pending
|
|
480
|
+
|
|
481
|
+
if pending_permission is not None:
|
|
482
|
+
waiting = True
|
|
483
|
+
needs = f"approve {pending_permission}"
|
|
484
|
+
elif last_phase == "permission_prompt":
|
|
485
|
+
waiting = True
|
|
486
|
+
needs = "approve a tool call"
|
|
487
|
+
|
|
488
|
+
if last_turn_outcome == "completed":
|
|
489
|
+
completed = True
|
|
490
|
+
|
|
491
|
+
return waiting, needs, completed
|
fleetwatch/cli.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""fleetwatch command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="fleetwatch",
|
|
14
|
+
description="Live plain-language status of every terminal coding session you have running.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--once", action="store_true",
|
|
18
|
+
help="print one text snapshot and exit (no live screen)",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--export-json", action="store_true",
|
|
22
|
+
help="print current sessions as JSON and exit (for scripting / remote export)",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--no-model", action="store_true",
|
|
26
|
+
help="disable model summaries; heuristics only",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--summarize-all", action="store_true",
|
|
30
|
+
help="full sweep: summarize every session before printing (pairs with --export-json)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--vendors",
|
|
34
|
+
help="comma-separated subset of vendors to watch (default: claude,codex,grok,gemini)",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--hosts",
|
|
38
|
+
help="comma-separated remote hosts to watch over ssh: name or name=ssh_target "
|
|
39
|
+
"(e.g. dreamer=luke@dr.eamer.dev). 'local' is always included.",
|
|
40
|
+
)
|
|
41
|
+
args = parser.parse_args(argv)
|
|
42
|
+
|
|
43
|
+
# CLI flags are translated to env so config.py stays the single source of truth.
|
|
44
|
+
if args.no_model:
|
|
45
|
+
os.environ["FLEETWATCH_NO_MODEL"] = "1"
|
|
46
|
+
if args.vendors:
|
|
47
|
+
os.environ["FLEETWATCH_VENDORS"] = args.vendors
|
|
48
|
+
if args.hosts is not None:
|
|
49
|
+
os.environ["FLEETWATCH_HOSTS"] = args.hosts
|
|
50
|
+
|
|
51
|
+
from .core import Aggregator
|
|
52
|
+
|
|
53
|
+
# --export-json describes THIS host only, so a remote pulling our export
|
|
54
|
+
# never recurses into our own configured hosts.
|
|
55
|
+
agg = Aggregator(hosts=[]) if args.export_json else Aggregator()
|
|
56
|
+
agg.refresh()
|
|
57
|
+
|
|
58
|
+
if args.export_json or args.once:
|
|
59
|
+
# One-shot modes have no refresh loop, so optionally sweep, then give
|
|
60
|
+
# background summaries a moment to land before we print.
|
|
61
|
+
if args.summarize_all:
|
|
62
|
+
agg.summarize_all()
|
|
63
|
+
agg.summarizer.drain(timeout=30 if args.summarize_all else 6)
|
|
64
|
+
|
|
65
|
+
if args.export_json:
|
|
66
|
+
print(json.dumps([s.to_dict() for s in agg.sessions()], indent=2))
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
if args.once:
|
|
70
|
+
from .render import render_snapshot
|
|
71
|
+
# Color on a terminal, plain when piped to a file or a log.
|
|
72
|
+
print(render_snapshot(
|
|
73
|
+
agg.sessions(), counts=agg.counts(), color=sys.stdout.isatty()
|
|
74
|
+
))
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
from .tui import run_tui
|
|
78
|
+
run_tui(agg)
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
sys.exit(main())
|
fleetwatch/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""User-tunable knobs. Environment variables override the defaults so the tool
|
|
2
|
+
is easy to script and to point at different setups."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _f(name: str, default: float) -> float:
|
|
10
|
+
try:
|
|
11
|
+
return float(os.environ.get(name, default))
|
|
12
|
+
except (TypeError, ValueError):
|
|
13
|
+
return default
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# A session counts as ACTIVE if its file changed within this many seconds.
|
|
17
|
+
ACTIVE_WINDOW = _f("FLEETWATCH_ACTIVE_WINDOW", 12)
|
|
18
|
+
|
|
19
|
+
# A finished session stays IDLE (not DONE) until it has been quiet this long.
|
|
20
|
+
DONE_AFTER = _f("FLEETWATCH_DONE_AFTER", 1800) # 30 minutes
|
|
21
|
+
|
|
22
|
+
# Sessions whose last activity is older than this are dropped from view.
|
|
23
|
+
MAX_AGE = _f("FLEETWATCH_MAX_AGE", 60 * 60 * 24 * 3) # 3 days
|
|
24
|
+
|
|
25
|
+
# How often the dashboard re-scans, in seconds.
|
|
26
|
+
REFRESH_INTERVAL = _f("FLEETWATCH_REFRESH", 2)
|
|
27
|
+
|
|
28
|
+
# Model used for plain-language summaries of sessions that need attention.
|
|
29
|
+
SUMMARY_MODEL = os.environ.get("FLEETWATCH_MODEL", "claude-haiku-4-5-20251001")
|
|
30
|
+
|
|
31
|
+
# Set FLEETWATCH_NO_MODEL=1 to stay fully offline (heuristic summaries only).
|
|
32
|
+
USE_MODEL = os.environ.get("FLEETWATCH_NO_MODEL", "") == ""
|
|
33
|
+
|
|
34
|
+
# Which vendor adapters to run.
|
|
35
|
+
ENABLED_VENDORS = [
|
|
36
|
+
v.strip()
|
|
37
|
+
for v in os.environ.get("FLEETWATCH_VENDORS", "claude,codex,grok,gemini").split(",")
|
|
38
|
+
if v.strip()
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Remote hosts to watch over ssh, comma-separated. Each entry is `name` or
|
|
42
|
+
# `name=ssh_target` (e.g. "dreamer" or "dreamer=luke@dr.eamer.dev").
|
|
43
|
+
REMOTE_HOSTS = os.environ.get("FLEETWATCH_HOSTS", "")
|