aw-watcher-agent 0.2.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.
- aw_watcher_agent/__init__.py +6 -0
- aw_watcher_agent/cc_tailer.py +262 -0
- aw_watcher_agent/cli.py +287 -0
- aw_watcher_agent/client.py +150 -0
- aw_watcher_agent/core.py +112 -0
- aw_watcher_agent/py.typed +0 -0
- aw_watcher_agent/tailer.py +256 -0
- aw_watcher_agent-0.2.0.dist-info/METADATA +188 -0
- aw_watcher_agent-0.2.0.dist-info/RECORD +11 -0
- aw_watcher_agent-0.2.0.dist-info/WHEEL +4 -0
- aw_watcher_agent-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""aw-watcher-agent: log AI coding-assistant sessions to a local aw-server."""
|
|
2
|
+
|
|
3
|
+
from .client import AWClient, AWClientError, Event, DEFAULT_SERVER, utc_now_iso
|
|
4
|
+
|
|
5
|
+
__all__ = ["AWClient", "AWClientError", "Event", "DEFAULT_SERVER", "utc_now_iso"]
|
|
6
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Claude Code session log-tailer: zero-config ``app.agent.session`` blocks.
|
|
2
|
+
|
|
3
|
+
Claude Code writes one JSONL transcript per session under
|
|
4
|
+
``~/.claude/projects/<project-slug>/<session-id>.jsonl`` (``CLAUDE_CONFIG_DIR``
|
|
5
|
+
moves the root). Every user/assistant row carries ``timestamp``, ``sessionId``
|
|
6
|
+
and ``cwd``. We read only those fields — never prompts, replies or tool
|
|
7
|
+
payloads — and turn each session into working blocks: consecutive rows closer
|
|
8
|
+
than ``gap_s`` (default 15 min) belong to one block, so a session left open
|
|
9
|
+
overnight does not paint the whole night as agent time.
|
|
10
|
+
|
|
11
|
+
Each finished block becomes one event in the same ``app.agent.session`` bucket
|
|
12
|
+
the lifecycle hooks write to, so hook users and tailer users see the same lane.
|
|
13
|
+
This is the no-hook path: run it from a timer and Claude Code work appears on
|
|
14
|
+
the Timeline without editing any harness configuration.
|
|
15
|
+
|
|
16
|
+
A cursor per transcript records how many blocks were emitted. The trailing
|
|
17
|
+
block is held back until it is older than ``gap_s``, so it is emitted once,
|
|
18
|
+
complete, instead of growing under repeated runs.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sqlite3
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Iterable
|
|
31
|
+
|
|
32
|
+
from . import core
|
|
33
|
+
from .client import AWClient, Event
|
|
34
|
+
|
|
35
|
+
DEFAULT_GAP_S = 15 * 60
|
|
36
|
+
_ROW_TYPES = ("user", "assistant")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_projects_dir() -> Path:
|
|
40
|
+
"""Root of Claude Code transcripts (honors ``CLAUDE_CONFIG_DIR``)."""
|
|
41
|
+
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
|
|
42
|
+
return Path(base) / "projects"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def transcripts(projects_dir: Path | None = None) -> list[Path]:
|
|
46
|
+
"""All top-level session transcripts, oldest first (subagent logs excluded)."""
|
|
47
|
+
root = projects_dir or default_projects_dir()
|
|
48
|
+
if not root.exists():
|
|
49
|
+
return []
|
|
50
|
+
return sorted(root.glob("*/*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class WorkBlock:
|
|
55
|
+
"""One contiguous stretch of activity within a Claude Code session."""
|
|
56
|
+
|
|
57
|
+
session_id: str
|
|
58
|
+
workspace: str
|
|
59
|
+
start: datetime
|
|
60
|
+
end: datetime
|
|
61
|
+
|
|
62
|
+
def to_event(self) -> Event:
|
|
63
|
+
data = core.session_data(
|
|
64
|
+
{
|
|
65
|
+
"harness": "claude-code",
|
|
66
|
+
"session_id": self.session_id,
|
|
67
|
+
"workspace": self.workspace,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
return Event(
|
|
71
|
+
timestamp=self.start.isoformat(),
|
|
72
|
+
duration=(self.end - self.start).total_seconds(),
|
|
73
|
+
data=data,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_ts(ts: Any) -> datetime | None:
|
|
78
|
+
if not isinstance(ts, str):
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
82
|
+
except ValueError:
|
|
83
|
+
return None
|
|
84
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_session(lines: Iterable[str], *, gap_s: float = DEFAULT_GAP_S) -> list[WorkBlock]:
|
|
88
|
+
"""Split one transcript into working blocks separated by gaps > ``gap_s``.
|
|
89
|
+
|
|
90
|
+
Sidechain rows (subagent turns inlined in the parent log) are ignored; they
|
|
91
|
+
overlap the parent's own rows and would only blur block boundaries.
|
|
92
|
+
"""
|
|
93
|
+
rows: list[tuple[datetime, str, str]] = []
|
|
94
|
+
for line in lines:
|
|
95
|
+
line = line.strip()
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
rec = json.loads(line)
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
continue
|
|
102
|
+
if not isinstance(rec, dict) or rec.get("type") not in _ROW_TYPES:
|
|
103
|
+
continue
|
|
104
|
+
if rec.get("isSidechain"):
|
|
105
|
+
continue
|
|
106
|
+
ts = _parse_ts(rec.get("timestamp"))
|
|
107
|
+
sid = rec.get("sessionId")
|
|
108
|
+
if ts is None or not isinstance(sid, str) or not sid:
|
|
109
|
+
continue
|
|
110
|
+
cwd = rec.get("cwd")
|
|
111
|
+
rows.append((ts, sid, cwd if isinstance(cwd, str) else ""))
|
|
112
|
+
|
|
113
|
+
rows.sort(key=lambda r: r[0])
|
|
114
|
+
blocks: list[WorkBlock] = []
|
|
115
|
+
for ts, sid, cwd in rows:
|
|
116
|
+
last = blocks[-1] if blocks else None
|
|
117
|
+
if last and last.session_id == sid and (ts - last.end).total_seconds() <= gap_s:
|
|
118
|
+
last.end = ts
|
|
119
|
+
last.workspace = cwd or last.workspace
|
|
120
|
+
else:
|
|
121
|
+
blocks.append(WorkBlock(session_id=sid, workspace=cwd, start=ts, end=ts))
|
|
122
|
+
return blocks
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _cursor_path(transcript: Path, server: str = "", hostname: str = "") -> Path:
|
|
126
|
+
identity = json.dumps([str(transcript.resolve()), server.rstrip("/"), hostname])
|
|
127
|
+
digest = hashlib.sha256(identity.encode()).hexdigest()
|
|
128
|
+
return core.state_dir() / f"cc-cursor-{digest}.json"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_emitted(
|
|
132
|
+
transcript: Path,
|
|
133
|
+
gap_s: float = DEFAULT_GAP_S,
|
|
134
|
+
*,
|
|
135
|
+
server: str = "",
|
|
136
|
+
hostname: str = "",
|
|
137
|
+
prefix_digests: list[str] | None = None,
|
|
138
|
+
) -> int:
|
|
139
|
+
try:
|
|
140
|
+
data = json.loads(_cursor_path(transcript, server, hostname).read_text(encoding="utf-8"))
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
return 0
|
|
143
|
+
count = data.get("emitted_blocks") if isinstance(data, dict) else None
|
|
144
|
+
if type(count) is not int or count < 0:
|
|
145
|
+
raise ValueError("Invalid Claude Code cursor: emitted_blocks must be a nonnegative integer")
|
|
146
|
+
if count > 0 and data.get("gap_s", DEFAULT_GAP_S) != gap_s:
|
|
147
|
+
raise ValueError("Cannot change --gap for a transcript with already emitted blocks")
|
|
148
|
+
if count > 0 and prefix_digests is not None:
|
|
149
|
+
if count > len(prefix_digests) or data.get("prefix_digest") != prefix_digests[count - 1]:
|
|
150
|
+
raise ValueError("Claude Code checkpointed prefix changed or cannot be verified")
|
|
151
|
+
return count
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _prefix_digests(blocks: list[WorkBlock]) -> list[str]:
|
|
155
|
+
"""Hash emitted event metadata incrementally, without storing transcript content."""
|
|
156
|
+
digest = hashlib.sha256()
|
|
157
|
+
prefixes = []
|
|
158
|
+
for block in blocks:
|
|
159
|
+
digest.update(json.dumps(block.to_event().to_payload(), sort_keys=True).encode())
|
|
160
|
+
digest.update(b"\n")
|
|
161
|
+
prefixes.append(digest.hexdigest())
|
|
162
|
+
return prefixes
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _write_emitted(
|
|
166
|
+
transcript: Path,
|
|
167
|
+
count: int,
|
|
168
|
+
gap_s: float = DEFAULT_GAP_S,
|
|
169
|
+
*,
|
|
170
|
+
server: str = "",
|
|
171
|
+
hostname: str = "",
|
|
172
|
+
prefix_digest: str | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
path = _cursor_path(transcript, server, hostname)
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
# The caller holds the transcript lock, so one fixed sibling temp is safe.
|
|
177
|
+
temporary = path.with_suffix(".tmp")
|
|
178
|
+
try:
|
|
179
|
+
temporary.write_text(
|
|
180
|
+
json.dumps({"emitted_blocks": count, "gap_s": gap_s, "prefix_digest": prefix_digest}),
|
|
181
|
+
encoding="utf-8",
|
|
182
|
+
)
|
|
183
|
+
temporary.replace(path)
|
|
184
|
+
finally:
|
|
185
|
+
temporary.unlink(missing_ok=True)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def emit_file(
|
|
189
|
+
client: AWClient,
|
|
190
|
+
hostname: str,
|
|
191
|
+
path: Path,
|
|
192
|
+
*,
|
|
193
|
+
gap_s: float = DEFAULT_GAP_S,
|
|
194
|
+
now: datetime | None = None,
|
|
195
|
+
) -> int:
|
|
196
|
+
"""Emit the finished, not-yet-emitted blocks of one transcript.
|
|
197
|
+
|
|
198
|
+
Returns the number of events posted. A block is finished once its last row
|
|
199
|
+
is more than ``gap_s`` old; the in-progress trailing block waits. If another
|
|
200
|
+
invocation owns this transcript, skip it until the next run.
|
|
201
|
+
"""
|
|
202
|
+
lock_path = _cursor_path(path, client.server, hostname).with_suffix(".lock.sqlite3")
|
|
203
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
# SQLite provides a cross-platform process lock, released even on a crash.
|
|
205
|
+
# Keep this separate from the JSON cursor and never unlink the lock file.
|
|
206
|
+
lock = sqlite3.connect(lock_path, timeout=0)
|
|
207
|
+
try:
|
|
208
|
+
try:
|
|
209
|
+
lock.execute("BEGIN IMMEDIATE")
|
|
210
|
+
except sqlite3.OperationalError as exc:
|
|
211
|
+
if str(exc) == "database is locked":
|
|
212
|
+
return 0
|
|
213
|
+
raise
|
|
214
|
+
return _emit_file_locked(client, hostname, path, gap_s=gap_s, now=now)
|
|
215
|
+
finally:
|
|
216
|
+
lock.close()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _emit_file_locked(
|
|
220
|
+
client: AWClient,
|
|
221
|
+
hostname: str,
|
|
222
|
+
path: Path,
|
|
223
|
+
*,
|
|
224
|
+
gap_s: float,
|
|
225
|
+
now: datetime | None,
|
|
226
|
+
) -> int:
|
|
227
|
+
now = now or datetime.now(timezone.utc)
|
|
228
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
229
|
+
lines = text.splitlines()
|
|
230
|
+
incomplete = False
|
|
231
|
+
if lines and not text.endswith("\n"):
|
|
232
|
+
try:
|
|
233
|
+
json.loads(lines[-1])
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
incomplete = True
|
|
236
|
+
blocks = parse_session(lines, gap_s=gap_s)
|
|
237
|
+
# A partially written row may extend the last parsed block. Earlier blocks
|
|
238
|
+
# already have a complete successor establishing their idle boundary.
|
|
239
|
+
candidates = blocks[:-1] if incomplete else blocks
|
|
240
|
+
finished = [b for b in candidates if (now - b.end).total_seconds() > gap_s]
|
|
241
|
+
prefix_digests = _prefix_digests(finished)
|
|
242
|
+
emitted = _read_emitted(
|
|
243
|
+
path, gap_s, server=client.server, hostname=hostname, prefix_digests=prefix_digests
|
|
244
|
+
)
|
|
245
|
+
new = finished[emitted:]
|
|
246
|
+
if not new:
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
bucket = core.bucket_id(hostname)
|
|
250
|
+
client.ensure_bucket(bucket, core.BUCKET_TYPE, core.CLIENT_NAME, hostname)
|
|
251
|
+
for block in new:
|
|
252
|
+
client.post_event(bucket, block.to_event())
|
|
253
|
+
emitted += 1
|
|
254
|
+
_write_emitted(
|
|
255
|
+
path,
|
|
256
|
+
emitted,
|
|
257
|
+
gap_s,
|
|
258
|
+
server=client.server,
|
|
259
|
+
hostname=hostname,
|
|
260
|
+
prefix_digest=prefix_digests[emitted - 1],
|
|
261
|
+
)
|
|
262
|
+
return len(new)
|
aw_watcher_agent/cli.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""``aw-watcher-agent`` CLI: emit AI-assistant session activity to a local aw-server.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
ensure-bucket Create the session bucket if missing.
|
|
5
|
+
emit-start Record the start of an AI session (zero-duration placeholder).
|
|
6
|
+
emit-end Replace the placeholder with one event carrying duration + outcome.
|
|
7
|
+
|
|
8
|
+
Designed to be called from harness lifecycle hooks (e.g. Claude Code
|
|
9
|
+
SessionStart/Stop). Failures are non-fatal by default (``--strict`` to opt in)
|
|
10
|
+
so a watcher problem never breaks the agent session it observes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import socket
|
|
17
|
+
import sqlite3
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
|
|
21
|
+
from .client import AWClient, AWClientError, Event, utc_now_iso
|
|
22
|
+
from . import core
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hostname(explicit: str | None) -> str:
|
|
26
|
+
return explicit or socket.gethostname()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_iso(ts: str) -> datetime:
|
|
30
|
+
return datetime.fromisoformat(ts)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _add_session_args(p: argparse.ArgumentParser) -> None:
|
|
34
|
+
p.add_argument("--harness", help="gptme / claude-code / codex")
|
|
35
|
+
p.add_argument("--model", help="resolved model id, e.g. claude-opus-4-7")
|
|
36
|
+
p.add_argument("--category", help="session category if known")
|
|
37
|
+
p.add_argument("--session-id", dest="session_id", help="harness session hash")
|
|
38
|
+
p.add_argument("--trigger", help="autonomous / interactive / timer")
|
|
39
|
+
p.add_argument("--workspace", help="workspace / agent name")
|
|
40
|
+
p.add_argument("--hostname", help="override hostname (default: socket.gethostname())")
|
|
41
|
+
p.add_argument("--server", default=core_default_server(), help="aw-server base URL")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def core_default_server() -> str:
|
|
45
|
+
from .client import DEFAULT_SERVER
|
|
46
|
+
|
|
47
|
+
return DEFAULT_SERVER
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_ensure_bucket(args: argparse.Namespace) -> int:
|
|
51
|
+
client = AWClient(args.server)
|
|
52
|
+
host = _hostname(args.hostname)
|
|
53
|
+
bid = core.bucket_id(host)
|
|
54
|
+
created = client.ensure_bucket(bid, core.BUCKET_TYPE, core.CLIENT_NAME, host)
|
|
55
|
+
print(f"bucket {bid}: {'created' if created else 'already exists'}")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_emit_start(args: argparse.Namespace) -> int:
|
|
60
|
+
client = AWClient(args.server)
|
|
61
|
+
host = _hostname(args.hostname)
|
|
62
|
+
bid = core.bucket_id(host)
|
|
63
|
+
client.ensure_bucket(bid, core.BUCKET_TYPE, core.CLIENT_NAME, host)
|
|
64
|
+
|
|
65
|
+
start = utc_now_iso()
|
|
66
|
+
data = core.session_data(vars(args))
|
|
67
|
+
event = Event(timestamp=start, duration=0.0, data=data)
|
|
68
|
+
# Use heartbeat, not POST /events: aw-server's events endpoint returns null,
|
|
69
|
+
# but heartbeat returns the created event (with id) so emit-end can delete
|
|
70
|
+
# this placeholder and leave a single clean block.
|
|
71
|
+
event_id = client.heartbeat(bid, event, pulsetime=0.0)
|
|
72
|
+
|
|
73
|
+
sid = args.session_id or start
|
|
74
|
+
core.write_state(sid, {"bucket_id": bid, "event_id": event_id, "start": start, "data": data})
|
|
75
|
+
print(f"session start emitted: {bid} event_id={event_id} session_id={sid}")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_emit_end(args: argparse.Namespace) -> int:
|
|
80
|
+
client = AWClient(args.server)
|
|
81
|
+
host = _hostname(args.hostname)
|
|
82
|
+
bid = core.bucket_id(host)
|
|
83
|
+
client.ensure_bucket(bid, core.BUCKET_TYPE, core.CLIENT_NAME, host)
|
|
84
|
+
|
|
85
|
+
sid = args.session_id or ""
|
|
86
|
+
state = core.read_state(sid) if sid else None
|
|
87
|
+
end = utc_now_iso()
|
|
88
|
+
|
|
89
|
+
if state and state.get("start"):
|
|
90
|
+
start = state["start"]
|
|
91
|
+
duration = (_parse_iso(end) - _parse_iso(start)).total_seconds()
|
|
92
|
+
# Drop the zero-duration placeholder so we end with one clean block.
|
|
93
|
+
if state.get("event_id") is not None:
|
|
94
|
+
if not client.delete_event(state.get("bucket_id", bid), state["event_id"]):
|
|
95
|
+
print(
|
|
96
|
+
f"warning: failed to delete placeholder event {state['event_id']} "
|
|
97
|
+
f"from {state.get('bucket_id', bid)} — timeline may contain duplicate block",
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
# No recorded start (crash / hook gap): fall back to provided duration.
|
|
102
|
+
start = end
|
|
103
|
+
duration = float(args.duration or 0.0)
|
|
104
|
+
|
|
105
|
+
# Merge saved start metadata with any fields supplied at emit-end time.
|
|
106
|
+
# CLI args at emit-end take precedence (allows corrections), outcome appended.
|
|
107
|
+
saved_data: dict[str, str] = (state.get("data") or {}) if state else {}
|
|
108
|
+
cli_overrides = core.session_data(vars(args))
|
|
109
|
+
merged = {**saved_data, **cli_overrides}
|
|
110
|
+
if args.outcome:
|
|
111
|
+
merged["outcome"] = str(args.outcome)
|
|
112
|
+
data = merged
|
|
113
|
+
event = Event(timestamp=start, duration=max(duration, 0.0), data=data)
|
|
114
|
+
# pulsetime=0 forces a fresh event (the outcome field differs from any prior
|
|
115
|
+
# block anyway); heartbeat returns the id for logging where POST does not.
|
|
116
|
+
event_id = client.heartbeat(bid, event, pulsetime=0.0)
|
|
117
|
+
if sid:
|
|
118
|
+
core.clear_state(sid)
|
|
119
|
+
print(
|
|
120
|
+
f"session end emitted: {bid} event_id={event_id} "
|
|
121
|
+
f"duration={duration:.1f}s outcome={args.outcome}"
|
|
122
|
+
)
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_emit_activity(args: argparse.Namespace) -> int:
|
|
127
|
+
client = AWClient(args.server)
|
|
128
|
+
host = _hostname(args.hostname)
|
|
129
|
+
bid = core.activity_bucket_id(host)
|
|
130
|
+
client.ensure_bucket(bid, core.ACTIVITY_BUCKET_TYPE, core.CLIENT_NAME, host)
|
|
131
|
+
|
|
132
|
+
duration_ms = max(int(args.duration_ms or 0), 0)
|
|
133
|
+
duration_s = duration_ms / 1000.0
|
|
134
|
+
start = datetime.now(timezone.utc) - timedelta(milliseconds=duration_ms)
|
|
135
|
+
event = Event(
|
|
136
|
+
timestamp=start.isoformat(),
|
|
137
|
+
duration=duration_s,
|
|
138
|
+
data=core.activity_data(vars(args)),
|
|
139
|
+
)
|
|
140
|
+
event_id = client.heartbeat(bid, event, pulsetime=float(args.pulsetime))
|
|
141
|
+
print(
|
|
142
|
+
f"activity emitted: {bid} event_id={event_id} "
|
|
143
|
+
f"tool={args.tool} duration_ms={duration_ms}"
|
|
144
|
+
)
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_tail_codex(args: argparse.Namespace) -> int:
|
|
149
|
+
from pathlib import Path
|
|
150
|
+
|
|
151
|
+
from . import tailer
|
|
152
|
+
|
|
153
|
+
client = AWClient(args.server)
|
|
154
|
+
host = _hostname(args.hostname)
|
|
155
|
+
|
|
156
|
+
path: Path | None = Path(args.file) if args.file else tailer.latest_rollout()
|
|
157
|
+
if path is None:
|
|
158
|
+
print("no Codex rollout transcripts found", file=sys.stderr)
|
|
159
|
+
return 0
|
|
160
|
+
count = tailer.emit_file(client, host, path, pulsetime=args.pulsetime)
|
|
161
|
+
bid = core.activity_bucket_id(host)
|
|
162
|
+
print(f"codex activity emitted: {bid} events={count} file={path.name}")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_tail_claude_code(args: argparse.Namespace) -> int:
|
|
167
|
+
from pathlib import Path
|
|
168
|
+
|
|
169
|
+
from . import cc_tailer
|
|
170
|
+
|
|
171
|
+
client = AWClient(args.server)
|
|
172
|
+
host = _hostname(args.hostname)
|
|
173
|
+
files = [Path(args.file)] if args.file else cc_tailer.transcripts()
|
|
174
|
+
if not files:
|
|
175
|
+
print("no Claude Code transcripts found", file=sys.stderr)
|
|
176
|
+
return 0
|
|
177
|
+
total = 0
|
|
178
|
+
failed = False
|
|
179
|
+
for path in files:
|
|
180
|
+
try:
|
|
181
|
+
total += cc_tailer.emit_file(client, host, path, gap_s=args.gap)
|
|
182
|
+
except (AWClientError, OSError, ValueError, sqlite3.Error) as exc:
|
|
183
|
+
failed = True
|
|
184
|
+
print(f"aw-watcher-agent: {path}: {exc}", file=sys.stderr)
|
|
185
|
+
print(
|
|
186
|
+
f"claude-code sessions emitted: {core.bucket_id(host)} "
|
|
187
|
+
f"events={total} transcripts={len(files)}"
|
|
188
|
+
)
|
|
189
|
+
return int(failed and args.strict)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
193
|
+
parser = argparse.ArgumentParser(prog="aw-watcher-agent", description=__doc__)
|
|
194
|
+
parser.add_argument("--strict", action="store_true", help="exit non-zero on errors")
|
|
195
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
196
|
+
|
|
197
|
+
p_ensure = sub.add_parser("ensure-bucket", help="create the session bucket")
|
|
198
|
+
p_ensure.add_argument("--hostname")
|
|
199
|
+
p_ensure.add_argument("--server", default=core_default_server())
|
|
200
|
+
p_ensure.set_defaults(func=cmd_ensure_bucket)
|
|
201
|
+
|
|
202
|
+
p_start = sub.add_parser("emit-start", help="record session start")
|
|
203
|
+
_add_session_args(p_start)
|
|
204
|
+
p_start.set_defaults(func=cmd_emit_start)
|
|
205
|
+
|
|
206
|
+
p_end = sub.add_parser("emit-end", help="record session end with outcome")
|
|
207
|
+
_add_session_args(p_end)
|
|
208
|
+
p_end.add_argument("--outcome", help="productive / blocked / noop")
|
|
209
|
+
p_end.add_argument("--duration", type=float, help="fallback duration (s) if no start state")
|
|
210
|
+
p_end.set_defaults(func=cmd_emit_end)
|
|
211
|
+
|
|
212
|
+
p_activity = sub.add_parser("emit-activity", help="record one tool activity heartbeat")
|
|
213
|
+
_add_session_args(p_activity)
|
|
214
|
+
p_activity.add_argument("--tool", required=True, help="tool name, e.g. shell")
|
|
215
|
+
p_activity.add_argument(
|
|
216
|
+
"--status",
|
|
217
|
+
default="success",
|
|
218
|
+
help="coarse tool status, e.g. success / error / completed",
|
|
219
|
+
)
|
|
220
|
+
p_activity.add_argument(
|
|
221
|
+
"--duration-ms",
|
|
222
|
+
dest="duration_ms",
|
|
223
|
+
type=int,
|
|
224
|
+
default=0,
|
|
225
|
+
help="tool duration in milliseconds",
|
|
226
|
+
)
|
|
227
|
+
p_activity.add_argument(
|
|
228
|
+
"--pulsetime",
|
|
229
|
+
type=float,
|
|
230
|
+
default=5.0,
|
|
231
|
+
help="heartbeat merge window in seconds (default: 5.0)",
|
|
232
|
+
)
|
|
233
|
+
p_activity.set_defaults(func=cmd_emit_activity)
|
|
234
|
+
|
|
235
|
+
p_tail = sub.add_parser(
|
|
236
|
+
"tail-codex", help="emit per-tool activity from a Codex rollout transcript"
|
|
237
|
+
)
|
|
238
|
+
p_tail.add_argument(
|
|
239
|
+
"--file", help="rollout JSONL to process (default: latest under ~/.codex/sessions)"
|
|
240
|
+
)
|
|
241
|
+
p_tail.add_argument(
|
|
242
|
+
"--pulsetime",
|
|
243
|
+
type=float,
|
|
244
|
+
default=5.0,
|
|
245
|
+
help="heartbeat merge window in seconds (default: 5.0)",
|
|
246
|
+
)
|
|
247
|
+
p_tail.add_argument("--hostname")
|
|
248
|
+
p_tail.add_argument("--server", default=core_default_server())
|
|
249
|
+
p_tail.set_defaults(func=cmd_tail_codex)
|
|
250
|
+
|
|
251
|
+
p_cc = sub.add_parser(
|
|
252
|
+
"tail-claude-code",
|
|
253
|
+
help="emit session working blocks from Claude Code transcripts (no hooks needed)",
|
|
254
|
+
)
|
|
255
|
+
p_cc.add_argument(
|
|
256
|
+
"--file", help="transcript JSONL to process (default: all under ~/.claude/projects)"
|
|
257
|
+
)
|
|
258
|
+
p_cc.add_argument(
|
|
259
|
+
"--gap",
|
|
260
|
+
type=float,
|
|
261
|
+
default=15 * 60,
|
|
262
|
+
help="idle gap in seconds that splits a session into blocks (default: 900)",
|
|
263
|
+
)
|
|
264
|
+
p_cc.add_argument("--hostname")
|
|
265
|
+
p_cc.add_argument("--server", default=core_default_server())
|
|
266
|
+
p_cc.set_defaults(func=cmd_tail_claude_code)
|
|
267
|
+
|
|
268
|
+
return parser
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main(argv: list[str] | None = None) -> int:
|
|
272
|
+
parser = build_parser()
|
|
273
|
+
args = parser.parse_args(argv)
|
|
274
|
+
try:
|
|
275
|
+
return int(args.func(args))
|
|
276
|
+
except (AWClientError, OSError, ValueError, sqlite3.Error) as exc:
|
|
277
|
+
msg = f"aw-watcher-agent: {exc}"
|
|
278
|
+
if args.strict:
|
|
279
|
+
print(msg, file=sys.stderr)
|
|
280
|
+
return 1
|
|
281
|
+
# Non-fatal by default: observing a session must never break it.
|
|
282
|
+
print(f"{msg} (ignored; use --strict to fail)", file=sys.stderr)
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Thin vendored ActivityWatch REST client (stdlib-only).
|
|
2
|
+
|
|
3
|
+
Phase 1 of aw-watcher-agent keeps zero heavy dependencies: it talks to a local
|
|
4
|
+
aw-server over its documented REST API using ``urllib`` instead of pulling in
|
|
5
|
+
``aw-client``/``aw-core``. The watcher therefore stays pip-installable and light
|
|
6
|
+
enough to ship as a hook target.
|
|
7
|
+
|
|
8
|
+
API shapes were verified against a live aw-server v0.13.2:
|
|
9
|
+
|
|
10
|
+
- ``GET /api/0/info`` -> ``{hostname, version, ...}``
|
|
11
|
+
- ``GET /api/0/buckets/`` -> ``{bucket_id: {id, type, client, hostname, ...}}``
|
|
12
|
+
- ``POST /api/0/buckets/{id}`` -> create bucket (idempotent-ish; 304 if exists)
|
|
13
|
+
- ``POST /api/0/buckets/{id}/events`` -> insert event(s); returns event with ``id``
|
|
14
|
+
- ``DELETE /api/0/buckets/{id}/events/{event_id}`` -> remove an event
|
|
15
|
+
- ``POST /api/0/buckets/{id}/heartbeat?pulsetime=N`` -> merge-extend an event
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
DEFAULT_SERVER = "http://127.0.0.1:5600"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def utc_now_iso() -> str:
|
|
31
|
+
"""Current UTC time as an ISO 8601 string aw-server accepts."""
|
|
32
|
+
return datetime.now(timezone.utc).isoformat()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Event:
|
|
37
|
+
"""An ActivityWatch event: a timestamped, durationed bag of string data."""
|
|
38
|
+
|
|
39
|
+
timestamp: str
|
|
40
|
+
duration: float
|
|
41
|
+
data: dict[str, Any]
|
|
42
|
+
|
|
43
|
+
def to_payload(self) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"timestamp": self.timestamp,
|
|
46
|
+
"duration": self.duration,
|
|
47
|
+
"data": self.data,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AWClientError(RuntimeError):
|
|
52
|
+
"""Raised when the aw-server returns an unrecoverable error."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AWClient:
|
|
56
|
+
"""Minimal aw-server REST client over the stdlib."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, server: str = DEFAULT_SERVER, timeout: float = 5.0) -> None:
|
|
59
|
+
self.server = server.rstrip("/")
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
|
|
62
|
+
def _request(self, method: str, path: str, body: Any | None = None) -> tuple[int, Any]:
|
|
63
|
+
url = f"{self.server}{path}"
|
|
64
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
65
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
66
|
+
if data is not None:
|
|
67
|
+
req.add_header("Content-Type", "application/json")
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
70
|
+
raw = resp.read()
|
|
71
|
+
parsed = json.loads(raw) if raw else None
|
|
72
|
+
return resp.status, parsed
|
|
73
|
+
except urllib.error.HTTPError as exc:
|
|
74
|
+
raw = exc.read()
|
|
75
|
+
try:
|
|
76
|
+
parsed = json.loads(raw) if raw else None
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
parsed = raw.decode(errors="replace")
|
|
79
|
+
return exc.code, parsed
|
|
80
|
+
|
|
81
|
+
# --- read ---------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def info(self) -> dict[str, Any]:
|
|
84
|
+
status, body = self._request("GET", "/api/0/info")
|
|
85
|
+
if status != 200 or not isinstance(body, dict):
|
|
86
|
+
raise AWClientError(f"unexpected /info response: {status} {body!r}")
|
|
87
|
+
return body
|
|
88
|
+
|
|
89
|
+
def buckets(self) -> dict[str, Any]:
|
|
90
|
+
status, body = self._request("GET", "/api/0/buckets/")
|
|
91
|
+
if status != 200 or not isinstance(body, dict):
|
|
92
|
+
raise AWClientError(f"unexpected /buckets response: {status} {body!r}")
|
|
93
|
+
return body
|
|
94
|
+
|
|
95
|
+
# --- bucket lifecycle ---------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def ensure_bucket(
|
|
98
|
+
self,
|
|
99
|
+
bucket_id: str,
|
|
100
|
+
event_type: str,
|
|
101
|
+
client_name: str,
|
|
102
|
+
hostname: str,
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""Create the bucket if missing. Returns True if newly created.
|
|
105
|
+
|
|
106
|
+
Idempotent: an already-existing bucket is treated as success rather than
|
|
107
|
+
an error, so the watcher can call this on every session start.
|
|
108
|
+
"""
|
|
109
|
+
if bucket_id in self.buckets():
|
|
110
|
+
return False
|
|
111
|
+
payload = {
|
|
112
|
+
"client": client_name,
|
|
113
|
+
"type": event_type,
|
|
114
|
+
"hostname": hostname,
|
|
115
|
+
}
|
|
116
|
+
status, body = self._request("POST", f"/api/0/buckets/{bucket_id}", payload)
|
|
117
|
+
# 200/201 = created, 304 = already existed (race with a parallel start)
|
|
118
|
+
if status in (200, 201, 304):
|
|
119
|
+
return status != 304
|
|
120
|
+
raise AWClientError(f"failed to create bucket {bucket_id}: {status} {body!r}")
|
|
121
|
+
|
|
122
|
+
# --- events -------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def post_event(self, bucket_id: str, event: Event) -> int | None:
|
|
125
|
+
"""Insert a single event. Returns the server-assigned event id if present."""
|
|
126
|
+
status, body = self._request(
|
|
127
|
+
"POST", f"/api/0/buckets/{bucket_id}/events", event.to_payload()
|
|
128
|
+
)
|
|
129
|
+
if status not in (200, 201):
|
|
130
|
+
raise AWClientError(f"failed to post event to {bucket_id}: {status} {body!r}")
|
|
131
|
+
if isinstance(body, dict):
|
|
132
|
+
return body.get("id")
|
|
133
|
+
if isinstance(body, list) and body and isinstance(body[0], dict):
|
|
134
|
+
return body[0].get("id")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def delete_event(self, bucket_id: str, event_id: int) -> bool:
|
|
138
|
+
status, _ = self._request("DELETE", f"/api/0/buckets/{bucket_id}/events/{event_id}")
|
|
139
|
+
return status in (200, 204)
|
|
140
|
+
|
|
141
|
+
def heartbeat(self, bucket_id: str, event: Event, pulsetime: float) -> int | None:
|
|
142
|
+
"""Merge-extend the latest event whose data matches, within ``pulsetime``."""
|
|
143
|
+
status, body = self._request(
|
|
144
|
+
"POST",
|
|
145
|
+
f"/api/0/buckets/{bucket_id}/heartbeat?pulsetime={pulsetime}",
|
|
146
|
+
event.to_payload(),
|
|
147
|
+
)
|
|
148
|
+
if status not in (200, 201):
|
|
149
|
+
raise AWClientError(f"failed to heartbeat {bucket_id}: {status} {body!r}")
|
|
150
|
+
return body.get("id") if isinstance(body, dict) else None
|
aw_watcher_agent/core.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Session bucket conventions and crash-robust state for aw-watcher-agent.
|
|
2
|
+
|
|
3
|
+
A session is modelled as a single event in the ``app.agent.session`` bucket.
|
|
4
|
+
``emit-start`` posts a zero-duration start event and records its server id; on
|
|
5
|
+
``emit-end`` we delete that placeholder and post one clean event carrying the
|
|
6
|
+
full duration plus ``outcome``. This yields exactly one Timeline block per
|
|
7
|
+
session with complete metadata.
|
|
8
|
+
|
|
9
|
+
Why not heartbeat-extend the start event? ActivityWatch only merges consecutive
|
|
10
|
+
events with *identical* ``data``. ``outcome`` is unknown until the session ends
|
|
11
|
+
and changes the payload, so a heartbeat with the outcome would not merge with
|
|
12
|
+
the start event. Delete-then-repost keeps a single block while still recording
|
|
13
|
+
the outcome. If ``emit-end`` never runs (crash), the zero-duration start event
|
|
14
|
+
still marks that the session began.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
BUCKET_TYPE = "app.agent.session"
|
|
25
|
+
CLIENT_NAME = "aw-watcher-agent"
|
|
26
|
+
|
|
27
|
+
# Per-tool activity events live in their own bucket type. Phase 2 originally
|
|
28
|
+
# proposed reusing the session bucket to keep "AI work" as a single Timeline
|
|
29
|
+
# lane, but session events (one long block spanning the whole run) and per-tool
|
|
30
|
+
# events (many short blocks) overlap in time, which ActivityWatch cannot render
|
|
31
|
+
# as a single clean lane. A sibling bucket keeps both lanes honest; aw-webui can
|
|
32
|
+
# still stack them. See the Phase 2 PR for the design note.
|
|
33
|
+
ACTIVITY_BUCKET_TYPE = "app.agent.activity"
|
|
34
|
+
|
|
35
|
+
# Stable session fields (everything except outcome, which is end-only).
|
|
36
|
+
START_FIELDS = ("harness", "model", "category", "session_id", "trigger", "workspace")
|
|
37
|
+
ACTIVITY_FIELDS = (
|
|
38
|
+
"harness",
|
|
39
|
+
"session_id",
|
|
40
|
+
"tool",
|
|
41
|
+
"status",
|
|
42
|
+
"model",
|
|
43
|
+
"category",
|
|
44
|
+
"trigger",
|
|
45
|
+
"workspace",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bucket_id(hostname: str) -> str:
|
|
50
|
+
"""Bucket id following the ``aw-watcher-<name>_<hostname>`` convention."""
|
|
51
|
+
return f"{CLIENT_NAME}_{hostname}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def activity_bucket_id(hostname: str) -> str:
|
|
55
|
+
"""Bucket id for per-tool activity events (sibling of the session bucket)."""
|
|
56
|
+
return f"{CLIENT_NAME}-activity_{hostname}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def state_dir() -> Path:
|
|
60
|
+
"""Per-session state directory (honors XDG_STATE_HOME)."""
|
|
61
|
+
base = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state")
|
|
62
|
+
return Path(base) / CLIENT_NAME
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def state_path(session_id: str) -> Path:
|
|
66
|
+
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in session_id)
|
|
67
|
+
return state_dir() / f"session-{safe}.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_state(session_id: str, payload: dict[str, Any]) -> Path:
|
|
71
|
+
path = state_path(session_id)
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_state(session_id: str) -> dict[str, Any] | None:
|
|
78
|
+
path = state_path(session_id)
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
except (json.JSONDecodeError, OSError):
|
|
84
|
+
return None
|
|
85
|
+
return data if isinstance(data, dict) else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clear_state(session_id: str) -> None:
|
|
89
|
+
path = state_path(session_id)
|
|
90
|
+
try:
|
|
91
|
+
path.unlink()
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def session_data(args: dict[str, Any], *, outcome: str | None = None) -> dict[str, str]:
|
|
97
|
+
"""Build the event ``data`` map from CLI args, dropping empty values."""
|
|
98
|
+
data = {f: str(args[f]) for f in START_FIELDS if args.get(f)}
|
|
99
|
+
if outcome:
|
|
100
|
+
data["outcome"] = str(outcome)
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def activity_data(args: dict[str, Any]) -> dict[str, str]:
|
|
105
|
+
"""Build per-tool activity event data, dropping empty values.
|
|
106
|
+
|
|
107
|
+
Raises ValueError if 'tool' is absent or empty — it is the semantic
|
|
108
|
+
discriminator for per-tool heartbeats and cannot be safely omitted.
|
|
109
|
+
"""
|
|
110
|
+
if not args.get("tool"):
|
|
111
|
+
raise ValueError("activity_data: 'tool' is required but missing or empty")
|
|
112
|
+
return {f: str(args[f]) for f in ACTIVITY_FIELDS if args.get(f)}
|
|
File without changes
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Codex transcript log-tailer: emit per-tool ``app.agent.activity`` heartbeats.
|
|
2
|
+
|
|
3
|
+
Phase 2 fallback for harnesses that cannot host an in-process gptme plugin hook.
|
|
4
|
+
Codex writes rollout transcripts to ``~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl``.
|
|
5
|
+
Each line is ``{"timestamp", "type", "payload"}``. A tool call is a
|
|
6
|
+
``response_item`` whose ``payload.type == "function_call"`` (carrying ``name``,
|
|
7
|
+
``call_id``, ``arguments``); its result is a later ``function_call_output`` with
|
|
8
|
+
the matching ``call_id``. We pair them, compute a duration, derive a coarse
|
|
9
|
+
status from the output, and emit one activity event per call into the
|
|
10
|
+
``aw-watcher-agent-activity_<hostname>`` bucket.
|
|
11
|
+
|
|
12
|
+
The parser (:func:`parse_rollout`) is pure and stdlib-only so it is testable
|
|
13
|
+
without a live aw-server or a real Codex session. Emission (:func:`emit_file`)
|
|
14
|
+
reuses the vendored :class:`~aw_watcher_agent.client.AWClient` and heartbeats so
|
|
15
|
+
adjacent calls of the same tool merge into a single Timeline block.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Iterable, cast
|
|
27
|
+
|
|
28
|
+
from .client import AWClient, Event
|
|
29
|
+
from . import core
|
|
30
|
+
|
|
31
|
+
# Codex marks shell exits as "Process exited with code N" / "exited with code N".
|
|
32
|
+
_EXIT_CODE_RE = re.compile(r"exited with code (\d+)")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Cursor helpers: prevent duplicate emission when tail-codex is re-run on the
|
|
37
|
+
# same rollout file (e.g. from a timer). We persist the 0-based index and
|
|
38
|
+
# timestamp of the last emitted activity so emit_file only sends new calls.
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _cursor_path(rollout_path: Path) -> Path:
|
|
43
|
+
"""Path to the cursor state file for a given rollout."""
|
|
44
|
+
safe = re.sub(r"[^a-zA-Z0-9._-]", "_", str(rollout_path.absolute()))
|
|
45
|
+
return core.state_dir() / f"cursor-{safe}.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_cursor(rollout_path: Path) -> dict[str, Any] | None:
|
|
49
|
+
"""Last-emitted cursor for *rollout_path*, or ``None``."""
|
|
50
|
+
path = _cursor_path(rollout_path)
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_cursor(rollout_path: Path, last_index: int, last_timestamp: str) -> None:
|
|
60
|
+
"""Persist the cursor after emitting activities."""
|
|
61
|
+
path = _cursor_path(rollout_path)
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
payload: dict[str, Any] = {"last_index": last_index, "last_timestamp": last_timestamp}
|
|
64
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def default_sessions_dir() -> Path:
|
|
68
|
+
"""Root of Codex rollout transcripts (honors ``CODEX_HOME``)."""
|
|
69
|
+
base = os.environ.get("CODEX_HOME") or os.path.expanduser("~/.codex")
|
|
70
|
+
return Path(base) / "sessions"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def latest_rollout(sessions_dir: Path | None = None) -> Path | None:
|
|
74
|
+
"""Most recently modified ``rollout-*.jsonl`` under ``sessions_dir``."""
|
|
75
|
+
root = sessions_dir or default_sessions_dir()
|
|
76
|
+
if not root.exists():
|
|
77
|
+
return None
|
|
78
|
+
files = sorted(root.rglob("rollout-*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
79
|
+
return files[-1] if files else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ToolActivity:
|
|
84
|
+
"""One paired Codex tool call: name, coarse status, and wall-clock duration."""
|
|
85
|
+
|
|
86
|
+
tool: str
|
|
87
|
+
status: str
|
|
88
|
+
duration_ms: int
|
|
89
|
+
timestamp: str # ISO 8601 start time (the function_call timestamp)
|
|
90
|
+
call_id: str
|
|
91
|
+
session_id: str
|
|
92
|
+
paired: bool = True # False when function_call_output not yet in transcript
|
|
93
|
+
|
|
94
|
+
def to_event(self, *, min_duration_s: float = 0.0) -> Event:
|
|
95
|
+
"""Build an AW event. ``data`` excludes duration so same-tool/status
|
|
96
|
+
calls merge under heartbeat; the per-call duration drives the block."""
|
|
97
|
+
data = core.activity_data(
|
|
98
|
+
{
|
|
99
|
+
"harness": "codex",
|
|
100
|
+
"session_id": self.session_id,
|
|
101
|
+
"tool": self.tool,
|
|
102
|
+
"status": self.status,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return Event(
|
|
106
|
+
timestamp=self.timestamp,
|
|
107
|
+
duration=max(self.duration_ms / 1000.0, min_duration_s),
|
|
108
|
+
data=data,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _status_from_output(output: Any) -> str:
|
|
113
|
+
"""Coarse success/error/completed status from a function_call_output."""
|
|
114
|
+
if not isinstance(output, str) or not output.strip():
|
|
115
|
+
return "completed"
|
|
116
|
+
match = _EXIT_CODE_RE.search(output)
|
|
117
|
+
if match:
|
|
118
|
+
return "success" if match.group(1) == "0" else "error"
|
|
119
|
+
return "completed"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_ts(ts: str) -> datetime | None:
|
|
123
|
+
try:
|
|
124
|
+
# Codex stamps RFC3339 with a trailing 'Z'; fromisoformat wants +00:00.
|
|
125
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
126
|
+
except (ValueError, AttributeError):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _iter_records(lines: Iterable[str]) -> Iterable[dict[str, Any]]:
|
|
131
|
+
for line in lines:
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
obj = json.loads(line)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
continue
|
|
139
|
+
if isinstance(obj, dict):
|
|
140
|
+
yield obj
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_rollout(lines: Iterable[str]) -> tuple[str, list[ToolActivity]]:
|
|
144
|
+
"""Parse Codex rollout JSONL into ``(session_id, [ToolActivity, ...])``.
|
|
145
|
+
|
|
146
|
+
Pairs each ``function_call`` with its matching ``function_call_output`` by
|
|
147
|
+
``call_id``. A call with no output (crash/truncation) is still emitted with
|
|
148
|
+
a zero duration and ``completed`` status so the Timeline marks that it ran.
|
|
149
|
+
"""
|
|
150
|
+
session_id = ""
|
|
151
|
+
# Preserve call order; map call_id -> pending call record.
|
|
152
|
+
pending: dict[str, dict[str, Any]] = {}
|
|
153
|
+
order: list[str] = []
|
|
154
|
+
|
|
155
|
+
for rec in _iter_records(lines):
|
|
156
|
+
payload = rec.get("payload")
|
|
157
|
+
if not isinstance(payload, dict):
|
|
158
|
+
continue
|
|
159
|
+
ptype = payload.get("type")
|
|
160
|
+
|
|
161
|
+
if rec.get("type") == "session_meta" and not session_id:
|
|
162
|
+
sid = payload.get("id")
|
|
163
|
+
if isinstance(sid, str):
|
|
164
|
+
session_id = sid
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if ptype == "function_call":
|
|
168
|
+
call_id = payload.get("call_id")
|
|
169
|
+
if not isinstance(call_id, str) or call_id in pending:
|
|
170
|
+
continue
|
|
171
|
+
pending[call_id] = {
|
|
172
|
+
"tool": str(payload.get("name") or "unknown"),
|
|
173
|
+
"start": rec.get("timestamp"),
|
|
174
|
+
}
|
|
175
|
+
order.append(call_id)
|
|
176
|
+
elif ptype == "function_call_output":
|
|
177
|
+
call_id = payload.get("call_id")
|
|
178
|
+
call = pending.get(call_id) if isinstance(call_id, str) else None
|
|
179
|
+
if call is not None:
|
|
180
|
+
call["end"] = rec.get("timestamp")
|
|
181
|
+
call["status"] = _status_from_output(payload.get("output"))
|
|
182
|
+
|
|
183
|
+
activities: list[ToolActivity] = []
|
|
184
|
+
for call_id in order:
|
|
185
|
+
call = pending[call_id]
|
|
186
|
+
start = call.get("start")
|
|
187
|
+
if not isinstance(start, str):
|
|
188
|
+
continue
|
|
189
|
+
duration_ms = 0
|
|
190
|
+
end = call.get("end")
|
|
191
|
+
if isinstance(end, str):
|
|
192
|
+
t0, t1 = _parse_ts(start), _parse_ts(end)
|
|
193
|
+
if t0 and t1:
|
|
194
|
+
duration_ms = max(int((t1 - t0).total_seconds() * 1000), 0)
|
|
195
|
+
activities.append(
|
|
196
|
+
ToolActivity(
|
|
197
|
+
tool=call["tool"],
|
|
198
|
+
status=call.get("status", "completed"),
|
|
199
|
+
duration_ms=duration_ms,
|
|
200
|
+
timestamp=start,
|
|
201
|
+
call_id=call_id,
|
|
202
|
+
session_id=session_id,
|
|
203
|
+
paired="end" in call,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return session_id, activities
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def emit_file(
|
|
210
|
+
client: AWClient,
|
|
211
|
+
hostname: str,
|
|
212
|
+
path: Path,
|
|
213
|
+
*,
|
|
214
|
+
pulsetime: float = 5.0,
|
|
215
|
+
) -> int:
|
|
216
|
+
"""Parse one rollout file and emit **new** tool activities since the last run.
|
|
217
|
+
|
|
218
|
+
Uses a cursor file (alongside the rollout) to skip previously-emitted
|
|
219
|
+
calls, so timer-driven runs do not duplicate historical heartbeats.
|
|
220
|
+
Returns the count of activities actually emitted this invocation.
|
|
221
|
+
|
|
222
|
+
``pulsetime`` controls heartbeat merging: consecutive same-tool/same-status
|
|
223
|
+
calls within this many seconds collapse into a single Timeline block.
|
|
224
|
+
"""
|
|
225
|
+
cursor = _read_cursor(path)
|
|
226
|
+
last_index = cursor.get("last_index", -1) if cursor else -1
|
|
227
|
+
|
|
228
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
229
|
+
_, activities = parse_rollout(text.splitlines())
|
|
230
|
+
|
|
231
|
+
# Only emit calls that are new since the last cursor position.
|
|
232
|
+
new = activities[last_index + 1 :]
|
|
233
|
+
if not new:
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
# Stop short of trailing unpaired (in-flight) calls so the cursor never
|
|
237
|
+
# advances past a call whose output hasn't arrived yet. Unpaired calls in
|
|
238
|
+
# the middle of the list (orphaned by a crash) are still emitted.
|
|
239
|
+
last_paired_pos = len(new) - 1
|
|
240
|
+
while last_paired_pos >= 0 and not new[last_paired_pos].paired:
|
|
241
|
+
last_paired_pos -= 1
|
|
242
|
+
|
|
243
|
+
to_emit = new[: last_paired_pos + 1]
|
|
244
|
+
if not to_emit:
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
bucket = core.activity_bucket_id(hostname)
|
|
248
|
+
client.ensure_bucket(bucket, core.ACTIVITY_BUCKET_TYPE, core.CLIENT_NAME, hostname)
|
|
249
|
+
for activity in to_emit:
|
|
250
|
+
client.heartbeat(bucket, activity.to_event(), pulsetime=pulsetime)
|
|
251
|
+
|
|
252
|
+
# Advance the cursor to the last emitted (paired) activity.
|
|
253
|
+
final_index = last_index + len(to_emit)
|
|
254
|
+
final_ts = to_emit[-1].timestamp
|
|
255
|
+
_write_cursor(path, final_index, final_ts)
|
|
256
|
+
return len(to_emit)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aw-watcher-agent
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: ActivityWatch watcher for AI coding assistants (gptme, Claude Code, Codex)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: activitywatch,ai-agents,aw-watcher,gptme,time-tracking
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# aw-watcher-agent
|
|
23
|
+
|
|
24
|
+
An [ActivityWatch](https://activitywatch.net) watcher for **AI coding
|
|
25
|
+
assistants** (gptme, Claude Code, Codex). It logs agent sessions and per-tool
|
|
26
|
+
activity to your **local** aw-server, so AI work shows up in the aw-webui
|
|
27
|
+
Timeline alongside window/AFK data instead of looking like idle time.
|
|
28
|
+
|
|
29
|
+
**Status:** alpha (`0.2.0`). Session tracking and the Codex log-tailer work and
|
|
30
|
+
are dogfooded against a local aw-server, as is the Claude Code log-tailer. A
|
|
31
|
+
native gptme plugin hook for per-tool events and an aw-webui "AI work" view are
|
|
32
|
+
not built yet.
|
|
33
|
+
|
|
34
|
+
This is the controller use case behind
|
|
35
|
+
[ActivityWatch/activitywatch#1215](https://github.com/ActivityWatch/activitywatch/issues/1215).
|
|
36
|
+
|
|
37
|
+
## Design
|
|
38
|
+
|
|
39
|
+
- **Local-only, privacy-first.** Writes go only to your own aw-server
|
|
40
|
+
(default `http://127.0.0.1:5600`). No hosted aggregation and no transcripts —
|
|
41
|
+
only coarse metadata (harness, model, category, tool name, status, counts).
|
|
42
|
+
- **No dependencies.** A small vendored stdlib REST client instead of
|
|
43
|
+
`aw-client` / `aw-core`, so it is light enough to call from a lifecycle hook.
|
|
44
|
+
- **One clean Timeline block per session.** `emit-start` posts a zero-duration
|
|
45
|
+
placeholder; `emit-end` deletes it and posts a single event with the full
|
|
46
|
+
duration plus `outcome`. If the session crashes before `emit-end`, the
|
|
47
|
+
placeholder still marks that it started.
|
|
48
|
+
- **Never breaks the session it observes.** Errors are reported on stderr and
|
|
49
|
+
the CLI exits 0, unless you pass `--strict`.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pipx install aw-watcher-agent # or: pip install aw-watcher-agent / uv tool install aw-watcher-agent
|
|
55
|
+
# latest unreleased master:
|
|
56
|
+
pip install "git+https://github.com/gptme/gptme-contrib#subdirectory=packages/aw-watcher-agent"
|
|
57
|
+
# or, from a gptme-contrib checkout:
|
|
58
|
+
pip install -e packages/aw-watcher-agent
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This installs the `aw-watcher-agent` command. You need a running aw-server
|
|
62
|
+
(ActivityWatch's default on port 5600).
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### Sessions
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Create the session bucket (idempotent; emit-start also does this)
|
|
70
|
+
aw-watcher-agent ensure-bucket
|
|
71
|
+
|
|
72
|
+
# At session start
|
|
73
|
+
aw-watcher-agent emit-start \
|
|
74
|
+
--harness claude-code --model claude-opus-4-7 \
|
|
75
|
+
--category code --session-id 8531 --trigger autonomous --workspace myagent
|
|
76
|
+
|
|
77
|
+
# At session end (reads the saved start state, records duration + outcome)
|
|
78
|
+
aw-watcher-agent emit-end --session-id 8531 --outcome productive
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Events land in the bucket `aw-watcher-agent_<hostname>` (type
|
|
82
|
+
`app.agent.session`), which appears in aw-webui automatically.
|
|
83
|
+
|
|
84
|
+
Session flags (shared by `emit-start`, `emit-end` and `emit-activity`):
|
|
85
|
+
`--harness`, `--model`, `--category`, `--session-id`, `--trigger`,
|
|
86
|
+
`--workspace`, `--hostname` (default: the machine hostname) and `--server`
|
|
87
|
+
(aw-server base URL). `emit-end` additionally takes `--outcome` and
|
|
88
|
+
`--duration` (seconds; used only when no start state was recorded). Values
|
|
89
|
+
passed to `emit-end` override the ones saved at start.
|
|
90
|
+
|
|
91
|
+
Start state is kept per session id under
|
|
92
|
+
`$XDG_STATE_HOME/aw-watcher-agent/` (default `~/.local/state/aw-watcher-agent/`).
|
|
93
|
+
|
|
94
|
+
### Claude Code without hooks (log-tailer)
|
|
95
|
+
|
|
96
|
+
`tail-claude-code` reads the transcripts Claude Code already writes under
|
|
97
|
+
`~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects`) and posts one
|
|
98
|
+
session event per working block, so Claude Code work shows up on the Timeline
|
|
99
|
+
with no harness configuration:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
aw-watcher-agent tail-claude-code # every transcript
|
|
103
|
+
aw-watcher-agent tail-claude-code --gap 600 # split blocks on 10 min idle
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Only `timestamp`, `sessionId` and `cwd` are read; prompts, replies and tool
|
|
107
|
+
payloads are never touched. A session is split into blocks on idle gaps
|
|
108
|
+
longer than `--gap` (default 15 min), so a session left open overnight is not
|
|
109
|
+
counted as agent time. The trailing block is held back until it has been idle
|
|
110
|
+
for `--gap`, and a per-transcript cursor records each successful post. Overlapping
|
|
111
|
+
invocations skip transcripts already being processed. Cursor writes are atomic;
|
|
112
|
+
retrying a failed later post does not replay the checkpointed prefix. Once blocks
|
|
113
|
+
have been emitted, changing `--gap` for that transcript is rejected rather than
|
|
114
|
+
posting overlapping representations. A digest of the checkpointed event metadata
|
|
115
|
+
also rejects historical inserts, rewrites or truncation that change already emitted
|
|
116
|
+
blocks; it does not silently skip or replay them. Only a missing cursor starts a
|
|
117
|
+
new import: unreadable, corrupt or older count-only cursors fail closed. Preserve
|
|
118
|
+
such a cursor and reconcile the destination events before resetting it, since
|
|
119
|
+
removing it reimports the transcript. A failed transcript is reported on stderr
|
|
120
|
+
without preventing other transcripts from being processed; `--strict` returns
|
|
121
|
+
nonzero after the scan if any failed. This is not transactional exactly-once
|
|
122
|
+
delivery: a crash after the server accepts a post but before its checkpoint can
|
|
123
|
+
still duplicate that last event on retry. Events land in the same session bucket
|
|
124
|
+
as the hooks below, with `harness=claude-code` and `workspace` set to the project
|
|
125
|
+
directory. Use either the tailer or lifecycle hooks for a session, not both.
|
|
126
|
+
|
|
127
|
+
### Wiring into Claude Code (or any harness with lifecycle hooks)
|
|
128
|
+
|
|
129
|
+
Point your harness's session-start / session-end hooks (for Claude Code:
|
|
130
|
+
`SessionStart` / `SessionEnd` in `settings.json`) at `emit-start` / `emit-end`,
|
|
131
|
+
passing the same `--session-id` to both. Do not use Claude Code's `Stop` hook for
|
|
132
|
+
`emit-end`: it fires after every response, so the first turn closes the session
|
|
133
|
+
and each later turn posts an empty 0s event. Keep the `SessionEnd` hook
|
|
134
|
+
synchronous (not `async`) so the post finishes before the process exits.
|
|
135
|
+
|
|
136
|
+
### Per-tool activity
|
|
137
|
+
|
|
138
|
+
Tool calls go to a sibling bucket, `aw-watcher-agent-activity_<hostname>`
|
|
139
|
+
(type `app.agent.activity`). Adjacent calls with the same tool and status within
|
|
140
|
+
`--pulsetime` seconds (default 5) merge into one Timeline block.
|
|
141
|
+
|
|
142
|
+
Emit a single tool event yourself, e.g. from a post-tool hook:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
aw-watcher-agent emit-activity --harness claude-code --session-id 8531 \
|
|
146
|
+
--tool shell --status success --duration-ms 1200
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Per-tool activity from Codex (log-tailer)
|
|
150
|
+
|
|
151
|
+
Codex can't host an in-process hook, so `tail-codex` reads its rollout
|
|
152
|
+
transcripts (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), pairs each
|
|
153
|
+
`function_call` with its `function_call_output`, derives a coarse
|
|
154
|
+
`success` / `error` / `completed` status, and emits one activity event per call:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Process the most recent rollout transcript
|
|
158
|
+
aw-watcher-agent tail-codex
|
|
159
|
+
|
|
160
|
+
# Or a specific transcript, with a custom merge window
|
|
161
|
+
aw-watcher-agent tail-codex --file ~/.codex/sessions/2026/05/29/rollout-...jsonl --pulsetime 5
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A cursor per transcript (in the same state directory) records what was already
|
|
165
|
+
emitted, so it is safe to run `tail-codex` repeatedly from a timer or after
|
|
166
|
+
each Codex run.
|
|
167
|
+
|
|
168
|
+
## Command reference
|
|
169
|
+
|
|
170
|
+
| Command | Purpose |
|
|
171
|
+
|---------|---------|
|
|
172
|
+
| `ensure-bucket` | Create the session bucket if missing |
|
|
173
|
+
| `emit-start` | Record session start (zero-duration placeholder) |
|
|
174
|
+
| `emit-end` | Replace the placeholder with one event carrying duration + `--outcome` |
|
|
175
|
+
| `emit-activity` | Record one tool-activity heartbeat (`--tool` required) |
|
|
176
|
+
| `tail-codex` | Emit per-tool activity from a Codex rollout transcript |
|
|
177
|
+
| `tail-claude-code` | Emit session working blocks from Claude Code transcripts (no hooks) |
|
|
178
|
+
|
|
179
|
+
Global flag: `--strict` (exit non-zero on errors). Run
|
|
180
|
+
`aw-watcher-agent <command> --help` for all options.
|
|
181
|
+
|
|
182
|
+
## Related
|
|
183
|
+
|
|
184
|
+
- [gptme-sessions](../gptme-sessions/README.md) — session tracking and
|
|
185
|
+
analytics on the agent side (this watcher puts the same sessions on your
|
|
186
|
+
ActivityWatch timeline).
|
|
187
|
+
- [gptme-activity-summary](../gptme-activity-summary/README.md) — summarizes
|
|
188
|
+
agent activity, including ActivityWatch data.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aw_watcher_agent/__init__.py,sha256=wpMwwnWSSYcRxwqK6BRNRpMSyZ23_KCY_VPY_mp6xRM,265
|
|
2
|
+
aw_watcher_agent/cc_tailer.py,sha256=afF28xiA3n7PWVHs23vupfigOSC1Mk3nDSQjR9TSLM4,9205
|
|
3
|
+
aw_watcher_agent/cli.py,sha256=6UhlXhuLoOg0qxFUm5mcRAJ1nyRyDqrEkM_P7qRuuLE,10610
|
|
4
|
+
aw_watcher_agent/client.py,sha256=LWOolub0ShatCtfI5s3yhqwcd5rWT2DMHW_eFPcgysQ,5789
|
|
5
|
+
aw_watcher_agent/core.py,sha256=nI3saWR_jhY7h2adn6o_UyP2U9fzolW8KkMpzSPi-RI,4020
|
|
6
|
+
aw_watcher_agent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
aw_watcher_agent/tailer.py,sha256=HmfNZ7lPzQ0mmuI2JyUTXo9WZBH1LIoV1w5oX0ZEofg,9513
|
|
8
|
+
aw_watcher_agent-0.2.0.dist-info/METADATA,sha256=fvT59ToNGnfmalTFXGjZ-dQib_nJbb2bg6INGvy43M0,8573
|
|
9
|
+
aw_watcher_agent-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
aw_watcher_agent-0.2.0.dist-info/entry_points.txt,sha256=bHLex5eNKGx3q4mKR6sImdHXSLM-dWsFwVMaGuFVDJ8,63
|
|
11
|
+
aw_watcher_agent-0.2.0.dist-info/RECORD,,
|