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.
@@ -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", "")