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,411 @@
|
|
|
1
|
+
"""Adapter for OpenAI Codex CLI sessions.
|
|
2
|
+
|
|
3
|
+
Codex writes one append-only JSONL "rollout" file per session under
|
|
4
|
+
``~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl``. Every line is a
|
|
5
|
+
record ``{"timestamp": "<iso>", "type": <kind>, "payload": {...}}``. The kinds
|
|
6
|
+
seen in real rollouts:
|
|
7
|
+
|
|
8
|
+
* ``session_meta`` — first line; ``payload`` carries ``id`` (the session id),
|
|
9
|
+
``cwd`` (the working directory), ``cli_version`` and ``source``.
|
|
10
|
+
* ``turn_context`` — per-turn settings: ``cwd``, ``approval_policy``,
|
|
11
|
+
``sandbox_policy``, ``model``.
|
|
12
|
+
* ``response_item`` — a model "response item". ``payload.type`` is one of:
|
|
13
|
+
- ``message`` with ``role`` in {developer, user, assistant} and a
|
|
14
|
+
``content`` list of ``{type: input_text|output_text, text}`` parts.
|
|
15
|
+
Assistant text is the agent's reply; ``user`` parts are often
|
|
16
|
+
instruction wrappers (AGENTS.md / environment_context), so the cleaner
|
|
17
|
+
user signal is the ``event_msg``/``user_message`` event below.
|
|
18
|
+
- ``reasoning`` (usually encrypted, ignored).
|
|
19
|
+
- ``function_call`` with ``name`` (e.g. ``exec_command``, ``update_plan``)
|
|
20
|
+
and a JSON-string ``arguments`` plus a ``call_id``.
|
|
21
|
+
- ``function_call_output`` with the matching ``call_id`` and ``output``.
|
|
22
|
+
- ``custom_tool_call`` / ``custom_tool_call_output`` (e.g. ``apply_patch``).
|
|
23
|
+
- ``web_search_call``.
|
|
24
|
+
* ``event_msg`` — UI events. ``payload.type`` includes ``task_started``,
|
|
25
|
+
``task_complete`` (carries ``last_agent_message``), ``user_message`` (clean
|
|
26
|
+
user text), ``agent_message`` (clean assistant text), ``token_count``,
|
|
27
|
+
``turn_aborted``, ``patch_apply_end``.
|
|
28
|
+
|
|
29
|
+
State detection (activity wins):
|
|
30
|
+
|
|
31
|
+
* ACTIVE — file mtime within ``ACTIVE_WINDOW``.
|
|
32
|
+
* WAITING — the agent is paused for the human. Codex run in interactive mode
|
|
33
|
+
pauses on a command/patch approval; on disk this shows up as a *dangling
|
|
34
|
+
tool call*: a ``function_call`` / ``custom_tool_call`` whose ``call_id`` has
|
|
35
|
+
no following ``*_output`` and which is not closed by a ``task_complete`` or
|
|
36
|
+
``turn_aborted``. When that holds and the file is stale (not ACTIVE), we
|
|
37
|
+
report WAITING with a ``needs`` describing the pending command/patch. (Fully
|
|
38
|
+
non-interactive ``exec`` sessions never strand a call this way; they finish
|
|
39
|
+
with ``task_complete``.)
|
|
40
|
+
* IDLE — turn finished (``task_complete`` is the last meaningful record) and
|
|
41
|
+
mtime younger than ``DONE_AFTER``.
|
|
42
|
+
* DONE — finished and mtime at least ``DONE_AFTER`` old.
|
|
43
|
+
* ERROR — the file could not be read at all.
|
|
44
|
+
|
|
45
|
+
``discover()`` returns ``[]`` when ``~/.codex/sessions`` is absent. When the
|
|
46
|
+
sessions tree is sparse it still works against whatever rollouts exist; the
|
|
47
|
+
global ``~/.codex/history.jsonl`` is only a fallback for ``last_user`` text.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import time
|
|
55
|
+
from typing import Optional
|
|
56
|
+
|
|
57
|
+
from ..config import ACTIVE_WINDOW, DONE_AFTER
|
|
58
|
+
from ..models import SessionState, State, TodoItem
|
|
59
|
+
from ..util import clean_command
|
|
60
|
+
from .base import Adapter, SessionRef, Source
|
|
61
|
+
|
|
62
|
+
# Codex nests rollouts as sessions/YYYY/MM/DD/rollout-*.jsonl. ``Source.glob``
|
|
63
|
+
# does not enable recursive ``**``, so we enumerate the date depth explicitly
|
|
64
|
+
# and also catch a flatter layout (older builds / fixtures) just in case.
|
|
65
|
+
SESSIONS_DIR = "~/.codex/sessions"
|
|
66
|
+
SESSIONS_GLOBS = (
|
|
67
|
+
"~/.codex/sessions/*/*/*/rollout-*.jsonl", # YYYY/MM/DD
|
|
68
|
+
"~/.codex/sessions/*/rollout-*.jsonl",
|
|
69
|
+
"~/.codex/sessions/rollout-*.jsonl",
|
|
70
|
+
)
|
|
71
|
+
HISTORY_PATH = "~/.codex/history.jsonl"
|
|
72
|
+
|
|
73
|
+
_TRUNC = 280
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _clip(text: str, limit: int = _TRUNC) -> str:
|
|
77
|
+
text = " ".join((text or "").split())
|
|
78
|
+
if len(text) <= limit:
|
|
79
|
+
return text
|
|
80
|
+
return text[: limit - 1].rstrip() + "…"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _payload(rec: dict) -> dict:
|
|
84
|
+
p = rec.get("payload")
|
|
85
|
+
return p if isinstance(p, dict) else {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _message_text(payload: dict) -> str:
|
|
89
|
+
"""Join the text parts of a response_item ``message`` payload."""
|
|
90
|
+
parts = payload.get("content")
|
|
91
|
+
if not isinstance(parts, list):
|
|
92
|
+
return ""
|
|
93
|
+
out = []
|
|
94
|
+
for part in parts:
|
|
95
|
+
if isinstance(part, dict):
|
|
96
|
+
txt = part.get("text")
|
|
97
|
+
if isinstance(txt, str) and txt.strip():
|
|
98
|
+
out.append(txt)
|
|
99
|
+
return "\n".join(out)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Wrapped, machine-generated "user" turns Codex injects (instructions, context,
|
|
103
|
+
# environment). They are not things the human typed, so skip them when hunting
|
|
104
|
+
# for the last real user message inside response_item records.
|
|
105
|
+
_NOISE_PREFIXES = (
|
|
106
|
+
"<permissions instructions>",
|
|
107
|
+
"<apps_instructions>",
|
|
108
|
+
"<skills_instructions>",
|
|
109
|
+
"<environment_context>",
|
|
110
|
+
"<user_instructions>",
|
|
111
|
+
"# AGENTS.md",
|
|
112
|
+
"## My request for Codex",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _looks_like_noise(text: str) -> bool:
|
|
117
|
+
stripped = text.lstrip()
|
|
118
|
+
return any(stripped.startswith(pfx) for pfx in _NOISE_PREFIXES)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _short_cmd(arguments: str) -> str:
|
|
122
|
+
"""Pull a human-ish command string out of a function_call's JSON args."""
|
|
123
|
+
try:
|
|
124
|
+
args = json.loads(arguments)
|
|
125
|
+
except (ValueError, TypeError):
|
|
126
|
+
return ""
|
|
127
|
+
if not isinstance(args, dict):
|
|
128
|
+
return ""
|
|
129
|
+
for key in ("cmd", "command", "shell", "script"):
|
|
130
|
+
val = args.get(key)
|
|
131
|
+
if isinstance(val, list):
|
|
132
|
+
val = " ".join(str(v) for v in val)
|
|
133
|
+
if isinstance(val, str) and val.strip():
|
|
134
|
+
return val.strip()
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _plan_to_todos(arguments: str) -> list[TodoItem]:
|
|
139
|
+
try:
|
|
140
|
+
args = json.loads(arguments)
|
|
141
|
+
except (ValueError, TypeError):
|
|
142
|
+
return []
|
|
143
|
+
plan = args.get("plan") if isinstance(args, dict) else None
|
|
144
|
+
if not isinstance(plan, list):
|
|
145
|
+
return []
|
|
146
|
+
todos: list[TodoItem] = []
|
|
147
|
+
for item in plan:
|
|
148
|
+
if not isinstance(item, dict):
|
|
149
|
+
continue
|
|
150
|
+
text = item.get("step") or item.get("text") or ""
|
|
151
|
+
if not isinstance(text, str) or not text.strip():
|
|
152
|
+
continue
|
|
153
|
+
status = item.get("status") or "pending"
|
|
154
|
+
if status not in ("pending", "in_progress", "completed"):
|
|
155
|
+
status = "pending"
|
|
156
|
+
todos.append(TodoItem(text=_clip(text, 120), status=status))
|
|
157
|
+
return todos
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CodexAdapter(Adapter):
|
|
161
|
+
vendor = "codex"
|
|
162
|
+
|
|
163
|
+
def discover(self, source: Source) -> list[SessionRef]:
|
|
164
|
+
if not source.exists(SESSIONS_DIR):
|
|
165
|
+
return []
|
|
166
|
+
refs: list[SessionRef] = []
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
for pattern in SESSIONS_GLOBS:
|
|
169
|
+
for path in source.glob(pattern):
|
|
170
|
+
if path in seen:
|
|
171
|
+
continue
|
|
172
|
+
seen.add(path)
|
|
173
|
+
refs.append(SessionRef(path=path, session_id=self._id_from_path(path)))
|
|
174
|
+
return refs
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _id_from_path(path: str) -> str:
|
|
178
|
+
name = os.path.basename(path)
|
|
179
|
+
if name.endswith(".jsonl"):
|
|
180
|
+
name = name[: -len(".jsonl")]
|
|
181
|
+
# rollout-<ISO timestamp>-<uuid>; the uuid is the stable id tail.
|
|
182
|
+
if name.startswith("rollout-"):
|
|
183
|
+
name = name[len("rollout-"):]
|
|
184
|
+
parts = name.rsplit("-", 5) # uuid is 5 dash-separated groups
|
|
185
|
+
if len(parts) == 6:
|
|
186
|
+
return "-".join(parts[1:])
|
|
187
|
+
return name
|
|
188
|
+
|
|
189
|
+
def read(
|
|
190
|
+
self, source: Source, ref: SessionRef, prev: Optional[SessionState]
|
|
191
|
+
) -> Optional[SessionState]:
|
|
192
|
+
try:
|
|
193
|
+
return self._read(source, ref, prev)
|
|
194
|
+
except Exception as exc: # never raise out of read()
|
|
195
|
+
return SessionState(
|
|
196
|
+
vendor=self.vendor,
|
|
197
|
+
session_id=ref.session_id,
|
|
198
|
+
project=os.path.basename(ref.cwd) if ref.cwd else ref.session_id,
|
|
199
|
+
cwd=ref.cwd,
|
|
200
|
+
source=getattr(source, "host", "local"),
|
|
201
|
+
state=State.ERROR,
|
|
202
|
+
error=f"codex read failed: {exc}",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _read(
|
|
206
|
+
self, source: Source, ref: SessionRef, prev: Optional[SessionState]
|
|
207
|
+
) -> Optional[SessionState]:
|
|
208
|
+
now = time.time()
|
|
209
|
+
mtime = source.mtime(ref.path)
|
|
210
|
+
recency = now - mtime
|
|
211
|
+
|
|
212
|
+
records = source.tail_records(ref.path)
|
|
213
|
+
if not records:
|
|
214
|
+
# Could be empty/missing/unreadable, or a tail that is one giant
|
|
215
|
+
# half-written line. Distinguish unreadable from genuinely empty.
|
|
216
|
+
if not source.exists(ref.path):
|
|
217
|
+
return SessionState(
|
|
218
|
+
vendor=self.vendor,
|
|
219
|
+
session_id=ref.session_id,
|
|
220
|
+
project=ref.session_id,
|
|
221
|
+
cwd=ref.cwd,
|
|
222
|
+
source=getattr(source, "host", "local"),
|
|
223
|
+
last_activity=mtime or None,
|
|
224
|
+
state=State.ERROR,
|
|
225
|
+
error="codex rollout not found",
|
|
226
|
+
)
|
|
227
|
+
return SessionState(
|
|
228
|
+
vendor=self.vendor,
|
|
229
|
+
session_id=ref.session_id,
|
|
230
|
+
project=ref.session_id,
|
|
231
|
+
cwd=ref.cwd,
|
|
232
|
+
source=getattr(source, "host", "local"),
|
|
233
|
+
last_activity=mtime or None,
|
|
234
|
+
state=State.ERROR,
|
|
235
|
+
error="codex rollout had no parseable records",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# --- identity: session meta if present, else the filename id ---
|
|
239
|
+
session_id = ref.session_id
|
|
240
|
+
cwd = ref.cwd
|
|
241
|
+
for rec in records:
|
|
242
|
+
if rec.get("type") == "session_meta":
|
|
243
|
+
p = _payload(rec)
|
|
244
|
+
if isinstance(p.get("id"), str):
|
|
245
|
+
session_id = p["id"]
|
|
246
|
+
if isinstance(p.get("cwd"), str):
|
|
247
|
+
cwd = p["cwd"]
|
|
248
|
+
break
|
|
249
|
+
# turn_context also carries cwd; prefer the latest one we see.
|
|
250
|
+
for rec in records:
|
|
251
|
+
if rec.get("type") == "turn_context":
|
|
252
|
+
c = _payload(rec).get("cwd")
|
|
253
|
+
if isinstance(c, str) and c:
|
|
254
|
+
cwd = c
|
|
255
|
+
|
|
256
|
+
project = os.path.basename(cwd.rstrip("/")) if cwd else session_id
|
|
257
|
+
|
|
258
|
+
# --- walk the tail collecting messages, calls, plans, completion ---
|
|
259
|
+
last_user = ""
|
|
260
|
+
last_agent = ""
|
|
261
|
+
todos: list[TodoItem] = []
|
|
262
|
+
open_calls: dict[str, dict] = {} # call_id -> {name, args}
|
|
263
|
+
last_open_call: Optional[dict] = None
|
|
264
|
+
completed = False # task finished / aborted after last call
|
|
265
|
+
last_doing = ""
|
|
266
|
+
|
|
267
|
+
for rec in records:
|
|
268
|
+
rtype = rec.get("type")
|
|
269
|
+
p = _payload(rec)
|
|
270
|
+
ptype = p.get("type")
|
|
271
|
+
|
|
272
|
+
if rtype == "event_msg":
|
|
273
|
+
if ptype == "user_message":
|
|
274
|
+
msg = p.get("message")
|
|
275
|
+
if isinstance(msg, str) and msg.strip():
|
|
276
|
+
last_user = msg
|
|
277
|
+
elif ptype == "agent_message":
|
|
278
|
+
msg = p.get("message")
|
|
279
|
+
if isinstance(msg, str) and msg.strip():
|
|
280
|
+
last_agent = msg
|
|
281
|
+
elif ptype == "task_complete":
|
|
282
|
+
completed = True
|
|
283
|
+
open_calls.clear()
|
|
284
|
+
last_open_call = None
|
|
285
|
+
msg = p.get("last_agent_message")
|
|
286
|
+
if isinstance(msg, str) and msg.strip():
|
|
287
|
+
last_agent = msg
|
|
288
|
+
elif ptype in ("turn_aborted", "task_started"):
|
|
289
|
+
# a new turn started, or the previous turn ended: any
|
|
290
|
+
# call that was dangling is no longer pending.
|
|
291
|
+
completed = ptype == "turn_aborted"
|
|
292
|
+
open_calls.clear()
|
|
293
|
+
last_open_call = None
|
|
294
|
+
|
|
295
|
+
elif rtype == "response_item":
|
|
296
|
+
if ptype == "message":
|
|
297
|
+
role = p.get("role")
|
|
298
|
+
text = _message_text(p)
|
|
299
|
+
if not text.strip():
|
|
300
|
+
continue
|
|
301
|
+
if role == "assistant":
|
|
302
|
+
last_agent = text
|
|
303
|
+
completed = False
|
|
304
|
+
elif role == "user" and not _looks_like_noise(text):
|
|
305
|
+
last_user = text
|
|
306
|
+
completed = False
|
|
307
|
+
elif ptype in ("function_call", "custom_tool_call"):
|
|
308
|
+
name = p.get("name") or ""
|
|
309
|
+
call_id = p.get("call_id")
|
|
310
|
+
arguments = p.get("arguments")
|
|
311
|
+
if not isinstance(arguments, str):
|
|
312
|
+
arguments = p.get("input") if isinstance(p.get("input"), str) else ""
|
|
313
|
+
if name == "update_plan":
|
|
314
|
+
mapped = _plan_to_todos(arguments)
|
|
315
|
+
if mapped:
|
|
316
|
+
todos = mapped
|
|
317
|
+
# plan updates are not pending approvals
|
|
318
|
+
continue
|
|
319
|
+
info = {"name": name, "args": arguments}
|
|
320
|
+
if isinstance(call_id, str):
|
|
321
|
+
open_calls[call_id] = info
|
|
322
|
+
last_open_call = info
|
|
323
|
+
completed = False
|
|
324
|
+
if name in ("exec_command", "shell", "local_shell"):
|
|
325
|
+
cmd = clean_command(_short_cmd(arguments))
|
|
326
|
+
last_doing = f"running: {cmd}" if cmd else "running a command"
|
|
327
|
+
elif name == "apply_patch":
|
|
328
|
+
last_doing = "applying a patch"
|
|
329
|
+
else:
|
|
330
|
+
last_doing = f"calling {name}" if name else "calling a tool"
|
|
331
|
+
elif ptype in ("function_call_output", "custom_tool_call_output"):
|
|
332
|
+
call_id = p.get("call_id")
|
|
333
|
+
if isinstance(call_id, str):
|
|
334
|
+
open_calls.pop(call_id, None)
|
|
335
|
+
last_open_call = None
|
|
336
|
+
elif ptype == "web_search_call":
|
|
337
|
+
completed = False
|
|
338
|
+
last_doing = "searching the web"
|
|
339
|
+
elif ptype == "reasoning":
|
|
340
|
+
if not last_doing:
|
|
341
|
+
last_doing = "thinking"
|
|
342
|
+
|
|
343
|
+
# --- pending (dangling) tool call = the WAITING signal ---
|
|
344
|
+
pending = None
|
|
345
|
+
if open_calls:
|
|
346
|
+
# the most recently opened still-unmatched call
|
|
347
|
+
pending = next(reversed(open_calls.values()))
|
|
348
|
+
elif last_open_call is not None:
|
|
349
|
+
pending = last_open_call
|
|
350
|
+
|
|
351
|
+
# --- decide state (activity wins) ---
|
|
352
|
+
needs: Optional[str] = None
|
|
353
|
+
if recency <= ACTIVE_WINDOW:
|
|
354
|
+
state = State.ACTIVE
|
|
355
|
+
doing = last_doing or "working"
|
|
356
|
+
elif pending is not None and not completed:
|
|
357
|
+
state = State.WAITING
|
|
358
|
+
name = pending.get("name") or ""
|
|
359
|
+
if name in ("exec_command", "shell", "local_shell"):
|
|
360
|
+
cmd = clean_command(_short_cmd(pending.get("args", "")))
|
|
361
|
+
needs = (
|
|
362
|
+
f"waiting on command approval: {cmd}"
|
|
363
|
+
if cmd
|
|
364
|
+
else "waiting on command approval"
|
|
365
|
+
)
|
|
366
|
+
doing = f"awaiting approval: {cmd}" if cmd else "awaiting approval"
|
|
367
|
+
elif name == "apply_patch":
|
|
368
|
+
needs = "waiting on patch approval"
|
|
369
|
+
doing = "awaiting patch approval"
|
|
370
|
+
else:
|
|
371
|
+
needs = f"waiting on {name or 'tool'} result"
|
|
372
|
+
doing = "awaiting approval"
|
|
373
|
+
elif recency < DONE_AFTER:
|
|
374
|
+
state = State.IDLE
|
|
375
|
+
doing = last_doing or "finished a turn"
|
|
376
|
+
else:
|
|
377
|
+
state = State.DONE
|
|
378
|
+
doing = last_doing or "finished a turn"
|
|
379
|
+
|
|
380
|
+
# --- last_user fallback: global history for this session ---
|
|
381
|
+
if not last_user:
|
|
382
|
+
last_user = self._history_last_user(source, session_id)
|
|
383
|
+
|
|
384
|
+
return SessionState(
|
|
385
|
+
vendor=self.vendor,
|
|
386
|
+
session_id=session_id,
|
|
387
|
+
project=project,
|
|
388
|
+
cwd=cwd,
|
|
389
|
+
source=getattr(source, "host", "local"),
|
|
390
|
+
last_activity=mtime or None,
|
|
391
|
+
state=state,
|
|
392
|
+
doing=doing,
|
|
393
|
+
needs=needs,
|
|
394
|
+
summary=None,
|
|
395
|
+
todos=todos,
|
|
396
|
+
last_user=_clip(last_user),
|
|
397
|
+
last_agent=_clip(last_agent),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _history_last_user(source: Source, session_id: str) -> str:
|
|
402
|
+
"""Fallback last_user from the global ~/.codex/history.jsonl."""
|
|
403
|
+
if not source.exists(HISTORY_PATH):
|
|
404
|
+
return ""
|
|
405
|
+
best = ""
|
|
406
|
+
for rec in source.tail_records(HISTORY_PATH):
|
|
407
|
+
if rec.get("session_id") == session_id:
|
|
408
|
+
text = rec.get("text")
|
|
409
|
+
if isinstance(text, str) and text.strip():
|
|
410
|
+
best = text
|
|
411
|
+
return best
|