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
fleetwatch/__init__.py
ADDED
|
@@ -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
|
+
)
|