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,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