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 ADDED
@@ -0,0 +1,3 @@
1
+ """fleetwatch — one screen for every terminal coding session you're running."""
2
+
3
+ __version__ = "0.3.1"
@@ -0,0 +1,36 @@
1
+ """Vendor adapters. ``all_adapters()`` returns the enabled, importable set."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+
8
+ from ..config import ENABLED_VENDORS
9
+ from .base import Adapter, LocalSource, SessionRef, Source
10
+
11
+ _REGISTRY = {
12
+ "claude": ("claude", "ClaudeAdapter"),
13
+ "codex": ("codex", "CodexAdapter"),
14
+ "grok": ("grok", "GrokAdapter"),
15
+ "gemini": ("gemini", "GeminiAdapter"),
16
+ }
17
+
18
+
19
+ def all_adapters() -> list[Adapter]:
20
+ """Instantiate every enabled adapter. An adapter that fails to import is
21
+ skipped with a warning rather than taking the whole tool down."""
22
+ out: list[Adapter] = []
23
+ for vendor in ENABLED_VENDORS:
24
+ spec = _REGISTRY.get(vendor)
25
+ if not spec:
26
+ continue
27
+ module_name, class_name = spec
28
+ try:
29
+ mod = importlib.import_module(f".{module_name}", __package__)
30
+ out.append(getattr(mod, class_name)())
31
+ except Exception as exc: # never let one adapter break the others
32
+ print(f"fleetwatch: skipping {vendor} adapter ({exc})", file=sys.stderr)
33
+ return out
34
+
35
+
36
+ __all__ = ["Adapter", "SessionRef", "Source", "LocalSource", "all_adapters"]
@@ -0,0 +1,112 @@
1
+ """The adapter contract and the local-filesystem Source.
2
+
3
+ Adapters never touch the filesystem directly; they go through a ``Source``.
4
+ Today the only Source is ``LocalSource``. A future ``RemoteSource`` (SSH, or a
5
+ pushed JSON export from each host) implements the same surface, so adapters work
6
+ unchanged against VPS sessions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import glob as _glob
12
+ import os
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass
15
+ from typing import Optional
16
+
17
+ from ..models import SessionState
18
+ from ..tailer import read_tail_lines, read_tail_records
19
+
20
+
21
+ @dataclass
22
+ class SessionRef:
23
+ """A pointer to one session's primary file, returned by ``discover()``."""
24
+
25
+ path: str # the primary transcript / history file
26
+ session_id: str
27
+ cwd: Optional[str] = None # decoded working directory when known
28
+ mtime: Optional[float] = None # freshness hint: newest mtime across a
29
+ # multi-file session. When set, the aggregator
30
+ # uses it instead of the primary file's mtime,
31
+ # so a change in any of the session's files
32
+ # (e.g. Grok's events.jsonl) is not missed.
33
+
34
+
35
+ class Source(ABC):
36
+ """Abstracts where session files live so the same adapters can read a remote
37
+ host later. Implementations must never raise on missing files."""
38
+
39
+ host: str = "local"
40
+
41
+ @abstractmethod
42
+ def expand(self, path: str) -> str: ...
43
+ @abstractmethod
44
+ def exists(self, path: str) -> bool: ...
45
+ @abstractmethod
46
+ def glob(self, pattern: str) -> list[str]: ...
47
+ @abstractmethod
48
+ def mtime(self, path: str) -> float: ...
49
+ @abstractmethod
50
+ def read_text(self, path: str) -> str: ...
51
+ @abstractmethod
52
+ def tail_lines(self, path: str, max_bytes: int = 512_000) -> list[str]: ...
53
+ @abstractmethod
54
+ def tail_records(self, path: str, max_bytes: int = 512_000) -> list[dict]: ...
55
+
56
+
57
+ class LocalSource(Source):
58
+ host = "local"
59
+
60
+ def expand(self, path: str) -> str:
61
+ return os.path.expanduser(os.path.expandvars(path))
62
+
63
+ def exists(self, path: str) -> bool:
64
+ return os.path.exists(self.expand(path))
65
+
66
+ def glob(self, pattern: str) -> list[str]:
67
+ return _glob.glob(self.expand(pattern))
68
+
69
+ def mtime(self, path: str) -> float:
70
+ try:
71
+ return os.path.getmtime(self.expand(path))
72
+ except OSError:
73
+ return 0.0
74
+
75
+ def read_text(self, path: str) -> str:
76
+ try:
77
+ with open(self.expand(path), "r", encoding="utf-8", errors="replace") as fh:
78
+ return fh.read()
79
+ except OSError:
80
+ return ""
81
+
82
+ def tail_lines(self, path: str, max_bytes: int = 512_000) -> list[str]:
83
+ return read_tail_lines(self.expand(path), max_bytes)
84
+
85
+ def tail_records(self, path: str, max_bytes: int = 512_000) -> list[dict]:
86
+ return read_tail_records(self.expand(path), max_bytes)
87
+
88
+
89
+ class Adapter(ABC):
90
+ """One adapter per vendor. Pure translation: vendor files in, SessionState out."""
91
+
92
+ vendor: str = "unknown"
93
+
94
+ @abstractmethod
95
+ def discover(self, source: Source) -> list[SessionRef]:
96
+ """Find candidate sessions for this vendor. Must be cheap — it runs every
97
+ refresh. Return ``[]`` when the vendor is not installed."""
98
+ ...
99
+
100
+ @abstractmethod
101
+ def read(
102
+ self, source: Source, ref: SessionRef, prev: Optional[SessionState]
103
+ ) -> Optional[SessionState]:
104
+ """Produce the current SessionState for one ref.
105
+
106
+ ``prev`` is the last state held for this session (or ``None`` on first
107
+ sight). Adapters may use it to carry context forward but must stay
108
+ correct when it is ``None``. Return ``None`` to skip a ref that is not
109
+ really a session. This method must not raise: on trouble, return a
110
+ SessionState with ``state=State.ERROR`` and a message in ``error``.
111
+ """
112
+ ...
@@ -0,0 +1,425 @@
1
+ """Claude Code adapter.
2
+
3
+ Claude Code keeps one directory per working tree under ``~/.claude/projects``.
4
+ The directory name is the cwd with every path separator (and other awkward
5
+ characters) rewritten as ``-`` — e.g. ``/Users/luke/workspace`` becomes
6
+ ``-Users-luke-workspace``. Inside each directory is one ``<session-uuid>.jsonl``
7
+ file per session (plus an occasional ``subagents/`` subdirectory, which we
8
+ ignore — those are not top-level sessions).
9
+
10
+ ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
11
+
12
+ Each line is one JSON record. The record shapes this adapter cares about, as
13
+ observed in the real transcripts on this machine:
14
+
15
+ ``type`` is the discriminator. The big two are ``"user"`` and ``"assistant"``;
16
+ the file is also peppered with bookkeeping records (``"attachment"``,
17
+ ``"summary"``, ``"system"``, ``"file-history-snapshot"``, ``"ai-title"``,
18
+ ``"mode"``, ``"last-prompt"``, ...) that have no ``message`` and are skipped.
19
+
20
+ A message-bearing record carries:
21
+
22
+ {
23
+ "type": "user" | "assistant",
24
+ "message": {"role": ..., "content": <str | [block, ...]>},
25
+ "timestamp": "2026-06-17T23:03:50.692Z", # ISO8601, UTC
26
+ "sessionId": "<uuid>",
27
+ "cwd": "/Users/luke/workspace",
28
+ "uuid": ..., "parentUuid": ...
29
+ }
30
+
31
+ ``message.content`` is either a plain string (a typed human prompt) or a list
32
+ of typed blocks. Block ``type`` is one of ``text``, ``thinking``, ``tool_use``,
33
+ ``tool_result`` (and a few others). A ``tool_use`` block has ``{id, name,
34
+ input}``; the matching ``tool_result`` block (which arrives inside a *later*
35
+ ``user`` record) carries ``tool_use_id`` pointing back at it.
36
+
37
+ WAITING — the whole point of the tool — is detected from a *dangling* tool_use:
38
+ the newest assistant ``tool_use`` whose ``id`` never shows up as a later
39
+ ``tool_result``'s ``tool_use_id``. If that exists and the file is stale (not
40
+ ACTIVE), the agent is parked waiting on a tool to run or a permission to be
41
+ granted. While the file is fresh, the same dangling tool_use just means a tool
42
+ is mid-flight, so the session is ACTIVE. An ``AskUserQuestion`` tool_use is an
43
+ explicit "blocked on the human" signal, and a stale plain-text turn ending in
44
+ ``?`` means the agent asked a question and is waiting on a reply.
45
+
46
+ Plans: the current Claude Code builds its task list with ``TaskCreate`` /
47
+ ``TaskUpdate`` tool calls rather than a single ``TodoWrite`` blob, so this
48
+ adapter reads both. ``TaskCreate`` input is ``{subject, description?,
49
+ activeForm?}`` and ``TaskUpdate`` input is ``{taskId, status}``; we replay them
50
+ in order to reconstruct the live task list. The classic ``TodoWrite`` shape
51
+ (``input.todos`` = ``[{content, status, activeForm?}]``) is still honored when
52
+ present.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import os
58
+ import time
59
+ from datetime import datetime
60
+ from typing import Optional
61
+
62
+ from ..config import ACTIVE_WINDOW, DONE_AFTER
63
+ from ..models import SessionState, State, TodoItem
64
+ from ..util import clean_command
65
+ from .base import Adapter, SessionRef, Source
66
+
67
+ PROJECTS_GLOB = "~/.claude/projects/*/*.jsonl"
68
+ PROJECTS_ROOT = "~/.claude/projects"
69
+
70
+
71
+ def _clip(text: str, limit: int = 280) -> str:
72
+ """Collapse whitespace and clip to ``limit`` chars for the detail pane."""
73
+ text = " ".join((text or "").split())
74
+ if len(text) <= limit:
75
+ return text
76
+ return text[: limit - 1].rstrip() + "…"
77
+
78
+
79
+ def _basename(path: Optional[str]) -> str:
80
+ if not path:
81
+ return ""
82
+ return os.path.basename(path.replace("\\", "/").rstrip("/"))
83
+
84
+
85
+ def _parse_ts(value) -> Optional[float]:
86
+ """ISO8601 (``...Z``) -> epoch seconds, or ``None`` if unparseable."""
87
+ if not isinstance(value, str) or not value:
88
+ return None
89
+ try:
90
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
91
+ except ValueError:
92
+ return None
93
+
94
+
95
+ def _decode_dir_cwd(path: str) -> Optional[str]:
96
+ """Best-effort recovery of a cwd from the encoded project directory name.
97
+
98
+ ``~/.claude/projects/-Users-luke-workspace/<uuid>.jsonl`` -> ``/Users/luke
99
+ /workspace``. This is lossy: Claude rewrites both ``/`` and literal ``-`` in
100
+ directory names as ``-``, so a real ``clocks-app`` is indistinguishable from
101
+ ``clocks/app``. It is only a fallback for the ``cwd`` field inside records,
102
+ which is exact.
103
+ """
104
+ parts = path.replace("\\", "/").rstrip("/").split("/")
105
+ if len(parts) < 2:
106
+ return None
107
+ name = parts[-2]
108
+ if not name.startswith("-"):
109
+ return None
110
+ return "/" + name[1:].replace("-", "/")
111
+
112
+
113
+ def _content_blocks(record: dict) -> list:
114
+ """The list of typed content blocks for a record, or ``[]`` for string/empty."""
115
+ msg = record.get("message")
116
+ if not isinstance(msg, dict):
117
+ return []
118
+ content = msg.get("content")
119
+ return content if isinstance(content, list) else []
120
+
121
+
122
+ def _content_str(record: dict) -> Optional[str]:
123
+ """The raw string content of a record, when ``message.content`` is a string."""
124
+ msg = record.get("message")
125
+ if not isinstance(msg, dict):
126
+ return None
127
+ content = msg.get("content")
128
+ return content if isinstance(content, str) else None
129
+
130
+
131
+ def _is_command_meta(text: str) -> bool:
132
+ """Slash-command scaffolding the UI shouldn't show as a real human prompt."""
133
+ return ("<command-name>" in text or "<local-command-" in text
134
+ or "<command-message>" in text)
135
+
136
+
137
+ def _record_text(record: dict, role: str) -> str:
138
+ """Human-readable text from a user/assistant record (string or text blocks)."""
139
+ s = _content_str(record)
140
+ if s is not None:
141
+ return "" if _is_command_meta(s) else s
142
+ chunks = []
143
+ for b in _content_blocks(record):
144
+ if isinstance(b, dict) and b.get("type") == "text":
145
+ t = b.get("text")
146
+ if isinstance(t, str):
147
+ chunks.append(t)
148
+ return " ".join(chunks)
149
+
150
+
151
+ def _doing_from_tool_use(block: dict) -> str:
152
+ """A short, human one-liner describing the latest tool the agent invoked."""
153
+ name = block.get("name") or "tool"
154
+ inp = block.get("input") if isinstance(block.get("input"), dict) else {}
155
+ if name in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
156
+ return f"editing {_basename(inp.get('file_path'))}".rstrip()
157
+ if name == "Read":
158
+ return f"reading {_basename(inp.get('file_path'))}".rstrip()
159
+ if name == "Bash":
160
+ cmd = clean_command(inp.get("command") or "")
161
+ return f"running: {cmd}" if cmd else "running a command"
162
+ if name in ("Task", "Agent"):
163
+ return "delegating to subagent"
164
+ if name in ("TodoWrite", "TaskCreate", "TaskUpdate"):
165
+ return "updating plan"
166
+ if name in ("Grep", "Glob"):
167
+ return "searching"
168
+ if name == "AskUserQuestion":
169
+ return "asking a question"
170
+ if name.startswith("mcp__"):
171
+ return f"running {name.split('__')[-1]}"
172
+ return f"running {name}"
173
+
174
+
175
+ def _status_of(item: dict) -> str:
176
+ status = item.get("status")
177
+ if status in ("pending", "in_progress", "completed"):
178
+ return status
179
+ return "pending"
180
+
181
+
182
+ def _todos_from_records(records: list) -> list[TodoItem]:
183
+ """Reconstruct the live task list from the transcript.
184
+
185
+ Two shapes are supported, newest-wins:
186
+
187
+ * Classic ``TodoWrite`` — ``input.todos`` is the whole list. The most recent
188
+ one replaces everything.
189
+ * Current ``TaskCreate`` / ``TaskUpdate`` — replayed in order. ``TaskCreate``
190
+ adds an item (keyed by position, since ``taskId`` is "1", "2", ... in
191
+ creation order); ``TaskUpdate`` flips an existing item's status.
192
+ """
193
+ todo_write: Optional[list[TodoItem]] = None
194
+ created: list[dict] = [] # ordered {"text", "status"} for TaskCreate/Update
195
+
196
+ for rec in records:
197
+ if rec.get("type") != "assistant":
198
+ continue
199
+ for b in _content_blocks(rec):
200
+ if not isinstance(b, dict) or b.get("type") != "tool_use":
201
+ continue
202
+ name = b.get("name")
203
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
204
+ if name == "TodoWrite":
205
+ items = inp.get("todos")
206
+ if isinstance(items, list):
207
+ todo_write = [
208
+ TodoItem(text=str(it.get("content", "")).strip(),
209
+ status=_status_of(it))
210
+ for it in items
211
+ if isinstance(it, dict) and it.get("content")
212
+ ]
213
+ elif name == "TaskCreate":
214
+ subject = (inp.get("subject")
215
+ or inp.get("activeForm")
216
+ or inp.get("description") or "").strip()
217
+ if subject:
218
+ created.append({"text": subject, "status": "pending"})
219
+ elif name == "TaskUpdate":
220
+ tid = inp.get("taskId")
221
+ status = inp.get("status")
222
+ try:
223
+ idx = int(tid) - 1
224
+ except (TypeError, ValueError):
225
+ idx = -1
226
+ if 0 <= idx < len(created) and status in (
227
+ "pending", "in_progress", "completed"
228
+ ):
229
+ created[idx]["status"] = status
230
+
231
+ if todo_write is not None:
232
+ return [t for t in todo_write if t.text]
233
+ return [TodoItem(text=c["text"], status=c["status"]) for c in created if c["text"]]
234
+
235
+
236
+ def _latest_assistant_tool_use(records: list) -> Optional[dict]:
237
+ """The newest assistant ``tool_use`` block in the transcript, or ``None``."""
238
+ for rec in reversed(records):
239
+ if rec.get("type") != "assistant":
240
+ continue
241
+ for b in reversed(_content_blocks(rec)):
242
+ if isinstance(b, dict) and b.get("type") == "tool_use":
243
+ return b
244
+ return None
245
+
246
+
247
+ def _resolved_tool_use_ids(records: list) -> set:
248
+ """Every ``tool_use_id`` that has a matching ``tool_result`` (i.e. completed)."""
249
+ done = set()
250
+ for rec in records:
251
+ for b in _content_blocks(rec):
252
+ if isinstance(b, dict) and b.get("type") == "tool_result":
253
+ tid = b.get("tool_use_id")
254
+ if tid:
255
+ done.add(tid)
256
+ return done
257
+
258
+
259
+ def _last_role_text(records: list, role: str) -> str:
260
+ """The most recent non-empty human-readable text for ``role``."""
261
+ rtype = "user" if role == "user" else "assistant"
262
+ for rec in reversed(records):
263
+ if rec.get("type") != rtype:
264
+ continue
265
+ text = _record_text(rec, role)
266
+ if text.strip():
267
+ return text
268
+ return ""
269
+
270
+
271
+ def _last_timestamp(records: list) -> Optional[float]:
272
+ for rec in reversed(records):
273
+ ts = _parse_ts(rec.get("timestamp"))
274
+ if ts is not None:
275
+ return ts
276
+ return None
277
+
278
+
279
+ def _cwd_from_records(records: list) -> Optional[str]:
280
+ for rec in reversed(records):
281
+ cwd = rec.get("cwd")
282
+ if isinstance(cwd, str) and cwd:
283
+ return cwd
284
+ return None
285
+
286
+
287
+ class ClaudeAdapter(Adapter):
288
+ vendor = "claude"
289
+
290
+ def discover(self, source: Source) -> list[SessionRef]:
291
+ try:
292
+ if not source.exists(PROJECTS_ROOT):
293
+ return []
294
+ refs: list[SessionRef] = []
295
+ for path in source.glob(PROJECTS_GLOB):
296
+ # Ignore subagent transcripts nested under <session>/subagents/.
297
+ norm = path.replace("\\", "/")
298
+ if "/subagents/" in norm:
299
+ continue
300
+ stem = _basename(path)
301
+ if stem.endswith(".jsonl"):
302
+ stem = stem[: -len(".jsonl")]
303
+ refs.append(
304
+ SessionRef(
305
+ path=path,
306
+ session_id=stem,
307
+ cwd=_decode_dir_cwd(path),
308
+ )
309
+ )
310
+ return refs
311
+ except Exception:
312
+ return []
313
+
314
+ def read(
315
+ self, source: Source, ref: SessionRef, prev: Optional[SessionState]
316
+ ) -> Optional[SessionState]:
317
+ try:
318
+ return self._read(source, ref)
319
+ except Exception as exc: # read() must never raise
320
+ return SessionState(
321
+ vendor=self.vendor,
322
+ session_id=ref.session_id,
323
+ project=_basename(ref.cwd) or ref.session_id,
324
+ cwd=ref.cwd,
325
+ source=getattr(source, "host", "local"),
326
+ last_activity=None,
327
+ state=State.ERROR,
328
+ error=f"read failed: {type(exc).__name__}",
329
+ )
330
+
331
+ def _read(self, source: Source, ref: SessionRef) -> Optional[SessionState]:
332
+ records = source.tail_records(ref.path)
333
+
334
+ now = time.time()
335
+ mtime = source.mtime(ref.path)
336
+ recency = now - mtime if mtime else None
337
+
338
+ cwd = _cwd_from_records(records) or ref.cwd
339
+ project = _basename(cwd) or ref.session_id
340
+
341
+ if not records:
342
+ # Empty or wholly unparseable tail — flag rather than fabricate state.
343
+ return SessionState(
344
+ vendor=self.vendor,
345
+ session_id=ref.session_id,
346
+ project=project,
347
+ cwd=cwd,
348
+ source=getattr(source, "host", "local"),
349
+ last_activity=mtime or None,
350
+ state=State.ERROR,
351
+ error="no readable records",
352
+ )
353
+
354
+ last_ts = _last_timestamp(records)
355
+ last_activity = mtime or last_ts
356
+
357
+ last_user = _clip(_last_role_text(records, "user"))
358
+ last_agent = _clip(_last_role_text(records, "assistant"))
359
+ todos = _todos_from_records(records)
360
+
361
+ latest_tool = _latest_assistant_tool_use(records)
362
+ resolved = _resolved_tool_use_ids(records)
363
+ dangling_tool = (
364
+ latest_tool is not None
365
+ and latest_tool.get("id") is not None
366
+ and latest_tool.get("id") not in resolved
367
+ )
368
+
369
+ doing = ""
370
+ if latest_tool is not None:
371
+ doing = _doing_from_tool_use(latest_tool)
372
+ elif last_agent:
373
+ doing = _clip(last_agent, 80)
374
+
375
+ is_active = recency is not None and recency <= ACTIVE_WINDOW
376
+ is_stale = not is_active
377
+
378
+ state = State.IDLE
379
+ needs: Optional[str] = None
380
+
381
+ if is_active:
382
+ # Activity beats everything. A dangling tool_use here is just a tool
383
+ # mid-run, not a block.
384
+ state = State.ACTIVE
385
+ else:
386
+ asking = (
387
+ latest_tool is not None
388
+ and latest_tool.get("name") == "AskUserQuestion"
389
+ )
390
+ ends_in_question = (
391
+ latest_tool is None
392
+ and bool(last_agent)
393
+ and last_agent.rstrip().endswith("?")
394
+ )
395
+ if dangling_tool and asking:
396
+ state = State.WAITING
397
+ needs = "answer a question"
398
+ elif dangling_tool:
399
+ state = State.WAITING
400
+ needs = "waiting on tool/permission"
401
+ elif ends_in_question:
402
+ state = State.WAITING
403
+ needs = "asked a question"
404
+ else:
405
+ # A completed turn: IDLE while recent, DONE once it goes cold.
406
+ if recency is not None and recency >= DONE_AFTER:
407
+ state = State.DONE
408
+ else:
409
+ state = State.IDLE
410
+
411
+ return SessionState(
412
+ vendor=self.vendor,
413
+ session_id=ref.session_id,
414
+ project=project,
415
+ cwd=cwd,
416
+ source=getattr(source, "host", "local"),
417
+ last_activity=last_activity,
418
+ state=state,
419
+ doing=doing,
420
+ needs=needs,
421
+ summary=None,
422
+ todos=todos,
423
+ last_user=last_user,
424
+ last_agent=last_agent,
425
+ )