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.
@@ -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)
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aw-watcher-agent = aw_watcher_agent.cli:main