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,377 @@
|
|
|
1
|
+
"""Gemini CLI adapter.
|
|
2
|
+
|
|
3
|
+
The Gemini CLI keeps one directory per working tree under ``~/.gemini/tmp``. The
|
|
4
|
+
directory name is the *basename* of the project (not the full encoded cwd, as
|
|
5
|
+
Claude does), e.g. a session run in ``/Users/luke/workspace/cyoa-ios`` lands in
|
|
6
|
+
``~/.gemini/tmp/cyoa-ios/``. Each such directory holds:
|
|
7
|
+
|
|
8
|
+
~/.gemini/tmp/<project>/.project_root one line: the full cwd
|
|
9
|
+
~/.gemini/tmp/<project>/chats/session-<ISO>-<8hex>.jsonl the transcript(s)
|
|
10
|
+
~/.gemini/tmp/<project>/logs.json compact [{...user prompt...}]
|
|
11
|
+
|
|
12
|
+
There is one ``session-*.jsonl`` per session and one JSON record per line. The
|
|
13
|
+
record shapes this adapter cares about, as observed in the real transcripts on
|
|
14
|
+
this machine:
|
|
15
|
+
|
|
16
|
+
* **header / ``main``** — the very first record. It has *no* ``type`` key;
|
|
17
|
+
instead it carries ``{"sessionId", "projectHash", "startTime", "lastUpdated",
|
|
18
|
+
"kind": "main"}``. ``sessionId`` is the canonical id.
|
|
19
|
+
* **``user``** — a turn from the human's side. ``content`` is a list. A *real*
|
|
20
|
+
human prompt is ``[{"text": "..."}]``; but Gemini also writes tool results
|
|
21
|
+
back as ``user`` records whose ``content`` is ``[{"functionResponse": {...}}]``
|
|
22
|
+
— those are NOT human text and must be skipped when picking ``last_user``.
|
|
23
|
+
* **``gemini``** — a model response: ``content`` is a string (often ``""`` while
|
|
24
|
+
the turn is still tool-calling), plus ``thoughts`` (array), ``toolCalls``
|
|
25
|
+
(array), ``tokens``, ``model``.
|
|
26
|
+
* **``info``** — UI/system notices (e.g. "an extension update is available").
|
|
27
|
+
Not a conversation turn; ignored.
|
|
28
|
+
* Trailing **``$set``** bookkeeping records (no ``type``) also appear; ignored.
|
|
29
|
+
|
|
30
|
+
Tool calls are NOT stranded across records the way Claude's are: each entry in a
|
|
31
|
+
``gemini`` record's ``toolCalls`` already carries its ``result`` (with an inline
|
|
32
|
+
``functionResponse``) and a ``status`` in the *same* record. There is therefore
|
|
33
|
+
no dangling-tool_use signal, and Gemini CLI has **no human-approval gate** — a
|
|
34
|
+
tool runs without pausing for the user to grant permission.
|
|
35
|
+
|
|
36
|
+
WAITING — by design this adapter NEVER emits ``State.WAITING``. There is no
|
|
37
|
+
reliable "blocked on the human" signal in this format: tool calls resolve inline
|
|
38
|
+
(no permission prompt to wait on), and a transcript that ends on a ``user``
|
|
39
|
+
record just means the model is still thinking (waiting on the *model*, not on the
|
|
40
|
+
human). That is ACTIVE if the file is fresh and IDLE/DONE once it goes cold. The
|
|
41
|
+
state machine is purely recency-driven:
|
|
42
|
+
|
|
43
|
+
ACTIVE : now - mtime <= ACTIVE_WINDOW
|
|
44
|
+
IDLE : ACTIVE_WINDOW < now - mtime < DONE_AFTER
|
|
45
|
+
DONE : now - mtime >= DONE_AFTER
|
|
46
|
+
ERROR : unreadable / no records
|
|
47
|
+
|
|
48
|
+
Todos: Gemini has no todo/plan tool in this format (its ``update_topic`` call is
|
|
49
|
+
a research-framing note, not a task list), so ``todos`` is always empty.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
import time
|
|
56
|
+
from datetime import datetime
|
|
57
|
+
from typing import Optional
|
|
58
|
+
|
|
59
|
+
from ..config import ACTIVE_WINDOW, DONE_AFTER
|
|
60
|
+
from ..models import SessionState, State
|
|
61
|
+
from .base import Adapter, SessionRef, Source
|
|
62
|
+
|
|
63
|
+
GEMINI_ROOT = "~/.gemini"
|
|
64
|
+
TMP_ROOT = "~/.gemini/tmp"
|
|
65
|
+
SESSIONS_GLOB = "~/.gemini/tmp/*/chats/session-*.jsonl"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clip(text: str, limit: int = 280) -> str:
|
|
69
|
+
"""Collapse whitespace and clip to ``limit`` chars for the detail pane."""
|
|
70
|
+
text = " ".join((text or "").split())
|
|
71
|
+
if len(text) <= limit:
|
|
72
|
+
return text
|
|
73
|
+
return text[: limit - 1].rstrip() + "…"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _basename(path: Optional[str]) -> str:
|
|
77
|
+
if not path:
|
|
78
|
+
return ""
|
|
79
|
+
return os.path.basename(path.replace("\\", "/").rstrip("/"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_ts(value) -> Optional[float]:
|
|
83
|
+
"""ISO8601 (``...Z``) -> epoch seconds, or ``None`` if unparseable."""
|
|
84
|
+
if not isinstance(value, str) or not value:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _project_dir(path: str) -> Optional[str]:
|
|
93
|
+
"""The ``<project>`` directory name for a ``.../tmp/<project>/chats/x.jsonl``.
|
|
94
|
+
|
|
95
|
+
That is the parent of ``chats/`` (i.e. the chats dir's parent's basename).
|
|
96
|
+
"""
|
|
97
|
+
parts = path.replace("\\", "/").rstrip("/").split("/")
|
|
98
|
+
# .../tmp/<project>/chats/<file>.jsonl -> <project> is parts[-3]
|
|
99
|
+
if len(parts) >= 3 and parts[-2] == "chats":
|
|
100
|
+
return parts[-3]
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _session_id_from_filename(path: str) -> str:
|
|
105
|
+
"""Best-effort session id from ``session-<ISO>-<8hex>.jsonl`` (fallback only)."""
|
|
106
|
+
stem = _basename(path)
|
|
107
|
+
if stem.endswith(".jsonl"):
|
|
108
|
+
stem = stem[: -len(".jsonl")]
|
|
109
|
+
return stem
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cwd_from_project_root(source: Source, path: str) -> Optional[str]:
|
|
113
|
+
"""Read the full cwd from the sibling ``.project_root`` file, if present.
|
|
114
|
+
|
|
115
|
+
``path`` is the session file; ``.project_root`` lives two levels up, beside
|
|
116
|
+
the ``chats/`` directory: ``.../tmp/<project>/.project_root``.
|
|
117
|
+
"""
|
|
118
|
+
norm = path.replace("\\", "/").rstrip("/")
|
|
119
|
+
parts = norm.split("/")
|
|
120
|
+
if len(parts) < 3 or parts[-2] != "chats":
|
|
121
|
+
return None
|
|
122
|
+
project_dir = "/".join(parts[:-2]) # drop "chats/<file>.jsonl"
|
|
123
|
+
root_file = project_dir + "/.project_root"
|
|
124
|
+
try:
|
|
125
|
+
if not source.exists(root_file):
|
|
126
|
+
return None
|
|
127
|
+
text = source.read_text(root_file).strip()
|
|
128
|
+
except Exception:
|
|
129
|
+
return None
|
|
130
|
+
return text or None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _user_text(record: dict) -> str:
|
|
134
|
+
"""Human-readable text from a ``user`` record.
|
|
135
|
+
|
|
136
|
+
``content`` is a list; real human prompts carry ``{"text": ...}`` items.
|
|
137
|
+
``functionResponse`` items (tool results echoed back as a user turn) are not
|
|
138
|
+
human text and contribute nothing.
|
|
139
|
+
"""
|
|
140
|
+
content = record.get("content")
|
|
141
|
+
if isinstance(content, str): # defensive; not seen in the wild
|
|
142
|
+
return content
|
|
143
|
+
if not isinstance(content, list):
|
|
144
|
+
return ""
|
|
145
|
+
chunks = []
|
|
146
|
+
for item in content:
|
|
147
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
148
|
+
chunks.append(item["text"])
|
|
149
|
+
return " ".join(chunks).strip()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_system_prompt(text: str) -> bool:
|
|
153
|
+
"""The session-bootstrap prompts the UI shouldn't show as a human turn.
|
|
154
|
+
|
|
155
|
+
Gemini seeds a session with a ``<session_context>`` block and ``/init``-style
|
|
156
|
+
instruction templates; treat those as scaffolding, not real prompts.
|
|
157
|
+
"""
|
|
158
|
+
head = text.lstrip()[:64]
|
|
159
|
+
return ("<session_context>" in text
|
|
160
|
+
or head.startswith("You are an AI agent")
|
|
161
|
+
or head.startswith("You are Gemini"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _gemini_text(record: dict) -> str:
|
|
165
|
+
"""The model's visible text for a ``gemini`` record (``content`` string)."""
|
|
166
|
+
content = record.get("content")
|
|
167
|
+
return content if isinstance(content, str) else ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _last_user(records: list) -> str:
|
|
171
|
+
"""The most recent real human prompt across the transcript."""
|
|
172
|
+
for rec in reversed(records):
|
|
173
|
+
if rec.get("type") != "user":
|
|
174
|
+
continue
|
|
175
|
+
text = _user_text(rec)
|
|
176
|
+
if text and not _is_system_prompt(text):
|
|
177
|
+
return text
|
|
178
|
+
return ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _last_agent(records: list) -> str:
|
|
182
|
+
"""The most recent non-empty model text across the transcript."""
|
|
183
|
+
for rec in reversed(records):
|
|
184
|
+
if rec.get("type") != "gemini":
|
|
185
|
+
continue
|
|
186
|
+
text = _gemini_text(rec)
|
|
187
|
+
if text.strip():
|
|
188
|
+
return text
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _last_user_from_logs(source: Source, session_path: str, session_id: str) -> str:
|
|
193
|
+
"""Fallback for ``last_user``: the compact ``logs.json`` beside the project.
|
|
194
|
+
|
|
195
|
+
``logs.json`` is a list of ``{sessionId, messageId, type, message, timestamp}``
|
|
196
|
+
records (the human prompts only). Used only when the transcript tail yields
|
|
197
|
+
no usable prompt (e.g. a long tool-only tail past the 0.5 MB window).
|
|
198
|
+
"""
|
|
199
|
+
norm = session_path.replace("\\", "/").rstrip("/")
|
|
200
|
+
parts = norm.split("/")
|
|
201
|
+
if len(parts) < 3 or parts[-2] != "chats":
|
|
202
|
+
return ""
|
|
203
|
+
logs_file = "/".join(parts[:-2]) + "/logs.json"
|
|
204
|
+
try:
|
|
205
|
+
if not source.exists(logs_file):
|
|
206
|
+
return ""
|
|
207
|
+
records = source.tail_records(logs_file)
|
|
208
|
+
except Exception:
|
|
209
|
+
return ""
|
|
210
|
+
# tail_records parses JSONL; logs.json is a single JSON array, so fall back to
|
|
211
|
+
# reading + json-loading it directly if the line parse came back empty.
|
|
212
|
+
if not records:
|
|
213
|
+
try:
|
|
214
|
+
import json as _json
|
|
215
|
+
|
|
216
|
+
data = _json.loads(source.read_text(logs_file))
|
|
217
|
+
records = data if isinstance(data, list) else []
|
|
218
|
+
except Exception:
|
|
219
|
+
return ""
|
|
220
|
+
best = ""
|
|
221
|
+
for rec in records:
|
|
222
|
+
if not isinstance(rec, dict):
|
|
223
|
+
continue
|
|
224
|
+
if rec.get("sessionId") not in (None, session_id):
|
|
225
|
+
continue
|
|
226
|
+
msg = rec.get("message")
|
|
227
|
+
if isinstance(msg, str) and msg.strip() and not msg.startswith("/"):
|
|
228
|
+
best = msg # keep walking; last match wins
|
|
229
|
+
return best
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _doing(records: list) -> str:
|
|
233
|
+
"""A short one-liner describing what the session is doing right now.
|
|
234
|
+
|
|
235
|
+
Driven by the last meaningful record:
|
|
236
|
+
|
|
237
|
+
* last record is a ``user`` turn -> "thinking" (model is composing a reply)
|
|
238
|
+
* last ``gemini`` record has tool calls -> "calling <fn>"
|
|
239
|
+
* otherwise the model produced text -> "responding"
|
|
240
|
+
"""
|
|
241
|
+
last_meaningful = None
|
|
242
|
+
for rec in reversed(records):
|
|
243
|
+
if rec.get("type") in ("user", "gemini"):
|
|
244
|
+
last_meaningful = rec
|
|
245
|
+
break
|
|
246
|
+
if last_meaningful is None:
|
|
247
|
+
return ""
|
|
248
|
+
if last_meaningful.get("type") == "user":
|
|
249
|
+
return "thinking"
|
|
250
|
+
tool_calls = last_meaningful.get("toolCalls")
|
|
251
|
+
if isinstance(tool_calls, list) and tool_calls:
|
|
252
|
+
last_call = tool_calls[-1]
|
|
253
|
+
name = ""
|
|
254
|
+
if isinstance(last_call, dict):
|
|
255
|
+
name = last_call.get("name") or last_call.get("displayName") or ""
|
|
256
|
+
return f"calling {name}".rstrip() if name else "calling a tool"
|
|
257
|
+
return "responding"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _session_id(records: list, ref: SessionRef) -> str:
|
|
261
|
+
"""The canonical id from the ``main`` header, else the filename fallback."""
|
|
262
|
+
for rec in records:
|
|
263
|
+
if rec.get("kind") == "main":
|
|
264
|
+
sid = rec.get("sessionId")
|
|
265
|
+
if isinstance(sid, str) and sid:
|
|
266
|
+
return sid
|
|
267
|
+
return ref.session_id
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _last_timestamp(records: list) -> Optional[float]:
|
|
271
|
+
for rec in reversed(records):
|
|
272
|
+
ts = _parse_ts(rec.get("timestamp"))
|
|
273
|
+
if ts is not None:
|
|
274
|
+
return ts
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class GeminiAdapter(Adapter):
|
|
279
|
+
vendor = "gemini"
|
|
280
|
+
|
|
281
|
+
def discover(self, source: Source) -> list[SessionRef]:
|
|
282
|
+
try:
|
|
283
|
+
if not source.exists(GEMINI_ROOT):
|
|
284
|
+
return []
|
|
285
|
+
refs: list[SessionRef] = []
|
|
286
|
+
for path in source.glob(SESSIONS_GLOB):
|
|
287
|
+
norm = path.replace("\\", "/")
|
|
288
|
+
if "/chats/" not in norm:
|
|
289
|
+
continue
|
|
290
|
+
refs.append(
|
|
291
|
+
SessionRef(
|
|
292
|
+
path=path,
|
|
293
|
+
session_id=_session_id_from_filename(path),
|
|
294
|
+
cwd=_cwd_from_project_root(source, path),
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
return refs
|
|
298
|
+
except Exception:
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
def read(
|
|
302
|
+
self, source: Source, ref: SessionRef, prev: Optional[SessionState]
|
|
303
|
+
) -> Optional[SessionState]:
|
|
304
|
+
try:
|
|
305
|
+
return self._read(source, ref)
|
|
306
|
+
except Exception as exc: # read() must never raise
|
|
307
|
+
return SessionState(
|
|
308
|
+
vendor=self.vendor,
|
|
309
|
+
session_id=ref.session_id,
|
|
310
|
+
project=_project_dir(ref.path) or _basename(ref.cwd) or ref.session_id,
|
|
311
|
+
cwd=ref.cwd,
|
|
312
|
+
source=getattr(source, "host", "local"),
|
|
313
|
+
last_activity=None,
|
|
314
|
+
state=State.ERROR,
|
|
315
|
+
error=f"read failed: {type(exc).__name__}",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _read(self, source: Source, ref: SessionRef) -> Optional[SessionState]:
|
|
319
|
+
records = source.tail_records(ref.path)
|
|
320
|
+
|
|
321
|
+
now = time.time()
|
|
322
|
+
mtime = source.mtime(ref.path)
|
|
323
|
+
recency = now - mtime if mtime else None
|
|
324
|
+
|
|
325
|
+
cwd = ref.cwd or _cwd_from_project_root(source, ref.path)
|
|
326
|
+
project = _project_dir(ref.path) or _basename(cwd) or ref.session_id
|
|
327
|
+
|
|
328
|
+
if not records:
|
|
329
|
+
# Empty or wholly unparseable tail — flag rather than fabricate state.
|
|
330
|
+
return SessionState(
|
|
331
|
+
vendor=self.vendor,
|
|
332
|
+
session_id=ref.session_id,
|
|
333
|
+
project=project,
|
|
334
|
+
cwd=cwd,
|
|
335
|
+
source=getattr(source, "host", "local"),
|
|
336
|
+
last_activity=mtime or None,
|
|
337
|
+
state=State.ERROR,
|
|
338
|
+
error="no readable records",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
session_id = _session_id(records, ref)
|
|
342
|
+
last_ts = _last_timestamp(records)
|
|
343
|
+
last_activity = mtime or last_ts
|
|
344
|
+
|
|
345
|
+
last_user = _last_user(records)
|
|
346
|
+
if not last_user:
|
|
347
|
+
last_user = _last_user_from_logs(source, ref.path, session_id)
|
|
348
|
+
last_user = _clip(last_user)
|
|
349
|
+
last_agent = _clip(_last_agent(records))
|
|
350
|
+
doing = _doing(records)
|
|
351
|
+
|
|
352
|
+
# Purely recency-driven. WAITING is intentionally never emitted: Gemini
|
|
353
|
+
# has no approval gate and resolves tool calls inline, so there is no
|
|
354
|
+
# reliable "blocked on the human" signal to key off of.
|
|
355
|
+
is_active = recency is not None and recency <= ACTIVE_WINDOW
|
|
356
|
+
if is_active:
|
|
357
|
+
state = State.ACTIVE
|
|
358
|
+
elif recency is not None and recency >= DONE_AFTER:
|
|
359
|
+
state = State.DONE
|
|
360
|
+
else:
|
|
361
|
+
state = State.IDLE
|
|
362
|
+
|
|
363
|
+
return SessionState(
|
|
364
|
+
vendor=self.vendor,
|
|
365
|
+
session_id=session_id,
|
|
366
|
+
project=project,
|
|
367
|
+
cwd=cwd,
|
|
368
|
+
source=getattr(source, "host", "local"),
|
|
369
|
+
last_activity=last_activity,
|
|
370
|
+
state=state,
|
|
371
|
+
doing=doing,
|
|
372
|
+
needs=None,
|
|
373
|
+
summary=None,
|
|
374
|
+
todos=[],
|
|
375
|
+
last_user=last_user,
|
|
376
|
+
last_agent=last_agent,
|
|
377
|
+
)
|