#!/usr/bin/env python3 """ context-timeline: Claude Code session visualizer. Modes (called by Claude Code hooks via stdin JSON): --server-start SessionStart hook — launch daemon + open browser --event PreToolUse / PostToolUse / Stop hook — notify server --shutdown Kill daemon manually --run-server Internal: daemon entry point (do not call directly) """ import argparse import re import gzip import io import json import os import pathlib import signal import socket import sys import threading import time import webbrowser import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from queue import Empty, Queue from urllib.parse import parse_qs, urlparse # ── Constants ────────────────────────────────────────────────────────────────── DEFAULT_PORT = 7878 DEFAULT_CONTEXT_LIMIT = 200_000 EXTENDED_CONTEXT_LIMIT = 1_000_000 WATCHDOG_SECONDS = 3600 CACHE_TTL = 0.25 _STATE_DIR = pathlib.Path.home() / ".claude" / "state" / "context-timeline" # ── Path helpers ─────────────────────────────────────────────────────────────── def _state_dir() -> pathlib.Path: _STATE_DIR.mkdir(parents=True, exist_ok=True) return _STATE_DIR def _encode_cwd(cwd: str) -> str: # Replace any non-alphanumeric character with "-" (cross-platform) return re.sub(r"[^a-zA-Z0-9]", "-", os.path.normpath(os.path.abspath(cwd))) def _transcript_path(session_id: str, cwd: str) -> pathlib.Path: base = pathlib.Path.home() / ".claude" / "projects" / _encode_cwd(cwd) return base / f"{session_id}.jsonl" def _subagents_dir(session_id: str, cwd: str) -> pathlib.Path: base = pathlib.Path.home() / ".claude" / "projects" / _encode_cwd(cwd) return base / session_id / "subagents" def _events_dir() -> pathlib.Path: d = _state_dir() / "events" d.mkdir(exist_ok=True) return d def _pid_file() -> pathlib.Path: return _state_dir() / "server.pid" def _port_file() -> pathlib.Path: return _state_dir() / "server.port" def _log_file() -> pathlib.Path: return _state_dir() / "server.log" def _sessions_file() -> pathlib.Path: return _state_dir() / "sessions.json" # ── Context limit detection ──────────────────────────────────────────────────── def _detect_context_limit(cwd: str) -> int: """Resolve the model context window in tokens. Resolution order: 1. CONTEXT_TIMELINE_LIMIT env var (explicit override). 2. "[1m]" / "[200k]" / "[1M]" suffix in the active model setting, read from /.claude/settings.json or ~/.claude/settings.json. 3. DEFAULT_CONTEXT_LIMIT (200_000). """ env = os.environ.get("CONTEXT_TIMELINE_LIMIT") if env: try: return int(env) except ValueError: pass for path in ( pathlib.Path(cwd) / ".claude" / "settings.json", pathlib.Path.home() / ".claude" / "settings.json", ): try: cfg = json.loads(path.read_text()) model = str(cfg.get("model", "")).lower() if "[1m]" in model or "[1000k]" in model: return EXTENDED_CONTEXT_LIMIT if "[200k]" in model: return DEFAULT_CONTEXT_LIMIT except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError): continue return DEFAULT_CONTEXT_LIMIT # ── Pid file ─────────────────────────────────────────────────────────────────── def _write_pid(pid: int, port: int): _pid_file().write_text(str(pid)) _port_file().write_text(str(port)) def _read_pid(): try: pid = int(_pid_file().read_text().strip()) port = int(_port_file().read_text().strip()) return pid, port except Exception: return 0, DEFAULT_PORT def _is_alive(pid: int) -> bool: if pid <= 0: return False try: os.kill(pid, 0) return True except OSError: return False def _remove_pidfile(): _pid_file().unlink(missing_ok=True) _port_file().unlink(missing_ok=True) # ── Port ─────────────────────────────────────────────────────────────────────── def _pick_port() -> int: env = os.environ.get("CONTEXT_TIMELINE_PORT") if env: return int(env) for p in range(DEFAULT_PORT, DEFAULT_PORT + 11): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: s.bind(("127.0.0.1", p)) return p except OSError: continue return DEFAULT_PORT def _wait_for_port(port: int, timeout=5.0) -> bool: deadline = time.time() + timeout while time.time() < deadline: try: with socket.create_connection(("127.0.0.1", port), timeout=0.2): return True except Exception: time.sleep(0.15) return False # ── Session registry ─────────────────────────────────────────────────────────── _sessions: dict = {} _sessions_lock = threading.Lock() def _register_session(session_id: str, cwd: str, transcript: str | None): entry = { "cwd": cwd, "transcript_path": transcript or str(_transcript_path(session_id, cwd)), "started_at": time.time(), } with _sessions_lock: _sessions[session_id] = entry try: sf = _sessions_file() existing: dict = json.loads(sf.read_text()) if sf.exists() else {} existing[session_id] = entry sf.write_text(json.dumps(existing)) except Exception: pass def _load_sessions(): try: sf = _sessions_file() if sf.exists(): data = json.loads(sf.read_text()) with _sessions_lock: _sessions.update(data) except Exception: pass def _get_cwd(session_id: str): with _sessions_lock: s = _sessions.get(session_id) if not s: _load_sessions() # daemon started before this session was registered with _sessions_lock: s = _sessions.get(session_id) return s["cwd"] if s else None # ── JSONL incremental reader ─────────────────────────────────────────────────── class _Tail: def __init__(self): self._offsets: dict[str, int] = {} def read(self, path: pathlib.Path) -> list: key = str(path) offset = self._offsets.get(key, 0) rows: list = [] try: with open(path, "rb") as f: f.seek(offset) while True: raw = f.readline() if not raw: break offset = f.tell() try: rows.append(json.loads(raw.decode("utf-8", errors="replace"))) except json.JSONDecodeError: pass self._offsets[key] = offset except (FileNotFoundError, OSError): pass return rows # ── State builder ────────────────────────────────────────────────────────────── def _tool_summary(name: str, inp: dict) -> str: if not inp: return "" if name == "Bash": return str(inp.get("command", ""))[:200] for k in ("file_path", "query", "pattern", "prompt", "description"): if k in inp: return str(inp[k])[:200] return str(inp)[:200] def _parse_ts(line: dict) -> float: raw = line.get("timestamp", "") if raw: try: from datetime import datetime, timezone # noqa: F401 return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() except Exception: pass return time.time() def _fresh_agent(aid: str, atype="", desc="") -> dict: return { "id": aid, "type": atype, "description": desc, "started_at": time.time(), "ended_at": None, "context": { "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "total_used": 0, }, "nodes": [], } def _fresh_state(session_id: str, limit: int = DEFAULT_CONTEXT_LIMIT) -> dict: return { "session_id": session_id, "started_at": time.time(), "main": _fresh_agent("main"), "subagents": {}, "context_window": { "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "total_used": 0, "limit": limit, "last_update_ts": time.time(), }, "edges": [], "version": 0, } class _Builder: def __init__(self, session_id: str, cwd: str): self._sid = session_id self._cwd = cwd self._limit = _detect_context_limit(cwd) self._state = _fresh_state(session_id, self._limit) self._tail = _Tail() def update(self) -> dict: s = self._state # Main transcript — skip sidechain lines for line in self._tail.read(_transcript_path(self._sid, self._cwd)): if not line.get("isSidechain", False): self._ingest(line, s["main"], s) # Subagent transcripts sub_dir = _subagents_dir(self._sid, self._cwd) if sub_dir.exists(): for jf in sorted(sub_dir.glob("agent-*.jsonl")): aid = jf.stem if aid not in s["subagents"]: meta: dict = {} try: meta = json.loads((sub_dir / f"{aid}.meta.json").read_text()) except Exception: pass s["subagents"][aid] = _fresh_agent( aid, meta.get("agentType", ""), meta.get("description", "") ) # Resolve oldest pending Task edge to this first new subagent for edge in s["edges"]: if edge["to_agent"] is None: edge["to_agent"] = aid break for line in self._tail.read(jf): self._ingest(line, s["subagents"][aid], s) s["version"] += 1 return s def _ingest(self, line: dict, agent: dict, s: dict): ltype = line.get("type", "") ts = _parse_ts(line) if ts and ts < s["started_at"]: s["started_at"] = ts if ltype == "assistant": msg = line.get("message") or {} usage = msg.get("usage") or {} if usage: ctx = { "input_tokens": usage.get("input_tokens", 0), "output_tokens": usage.get("output_tokens", 0), "cache_read_input_tokens": usage.get("cache_read_input_tokens", 0), "cache_creation_input_tokens": usage.get("cache_creation_input_tokens", 0), } ctx["total_used"] = sum(ctx.values()) agent["context"] = ctx if agent["id"] == "main": s["context_window"].update({**ctx, "last_update_ts": ts, "limit": self._limit}) for block in (msg.get("content") or []): if not isinstance(block, dict) or block.get("type") != "tool_use": continue name = block.get("name", "") node = { "uuid": block.get("id", f"t{ts}"), "parent_uuid": line.get("parentUuid"), "ts": ts, "type": "tool_use", "tool_name": name, "tool_input_summary": _tool_summary(name, block.get("input") or {}), "duration_ms": None, "status": "pending", "result_summary": None, } agent["nodes"].append(node) if name in ("Task", "Agent"): s["edges"].append({"from_node": node["uuid"], "to_agent": None, "ts": ts}) elif ltype == "user": msg = line.get("message") or {} for item in (msg.get("content") or []): if not isinstance(item, dict) or item.get("type") != "tool_result": continue tid = item.get("tool_use_id", "") is_err = bool(item.get("is_error")) content = item.get("content", "") if isinstance(content, list): content = " ".join( str(c.get("text", "")) for c in content if isinstance(c, dict) ) summary = str(content)[:200] _Builder._complete(agent, tid, is_err, summary) for sub in s["subagents"].values(): _Builder._complete(sub, tid, is_err, summary) @staticmethod def _complete(agent: dict, tid: str, is_err: bool, result: str): for n in agent["nodes"]: if n["uuid"] == tid and n["status"] == "pending": n["status"] = "error" if is_err else "ok" n["result_summary"] = result return # ── Global server state ──────────────────────────────────────────────────────── _cache: dict = {} # session_id → (state, built_at) _cache_lock = threading.Lock() _sse: dict = {} # session_id → list[Queue] _sse_lock = threading.Lock() _builders: dict = {} _builders_lock = threading.Lock() _last_req = [time.time()] def _push_sse(sid: str, msg: dict): with _sse_lock: subs = list(_sse.get(sid, [])) for q in subs: try: q.put_nowait(msg) except Exception: pass def _get_state(sid: str, force=False) -> dict: with _cache_lock: cached = _cache.get(sid) if not force and cached and (time.time() - cached[1]) < CACHE_TTL: return cached[0] cwd = _get_cwd(sid) if not cwd: return {"error": "session_not_found", "session_id": sid, "version": 0} with _builders_lock: if sid not in _builders: _builders[sid] = _Builder(sid, cwd) b = _builders[sid] try: state = b.update() except Exception as e: state = {"error": str(e), "session_id": sid, "version": 0} with _cache_lock: _cache[sid] = (state, time.time()) return state # ── Background threads ───────────────────────────────────────────────────────── def _wal_reaper(): offsets: dict = {} while True: try: for f in _events_dir().glob("*.jsonl"): sid = f.stem off = offsets.get(str(f), 0) try: with open(f, "rb") as fh: fh.seek(off) while True: raw = fh.readline() if not raw: break off = fh.tell() try: json.loads(raw) with _cache_lock: _cache.pop(sid, None) _push_sse(sid, {"type": "refresh"}) except Exception: pass offsets[str(f)] = off except Exception: pass except Exception: pass time.sleep(0.5) def _watchdog(): while True: time.sleep(60) if time.time() - _last_req[0] > WATCHDOG_SECONDS: os._exit(0) # ── Embedded dashboard ───────────────────────────────────────────────────────── _HTML = """ Claude Timeline
connecting…
Context Window
100% Ctrl+scroll
""" # ── HTTP handler ─────────────────────────────────────────────────────────────── class _Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass def _send(self, code, ct, body: bytes, extra=None): self.send_response(code) self.send_header("Content-Type", ct) self.send_header("Content-Length", len(body)) self.send_header("Access-Control-Allow-Origin", "*") for k, v in (extra or {}).items(): self.send_header(k, v) self.end_headers() self.wfile.write(body) def do_GET(self): _last_req[0] = time.time() parsed = urlparse(self.path) qs = parse_qs(parsed.query) path = parsed.path if path in ("/", ""): body = _HTML.encode("utf-8") if "gzip" in self.headers.get("Accept-Encoding", ""): buf = io.BytesIO() with gzip.GzipFile(fileobj=buf, mode="wb") as gz: gz.write(body) body = buf.getvalue() self._send(200, "text/html; charset=utf-8", body, {"Content-Encoding": "gzip"}) else: self._send(200, "text/html; charset=utf-8", body) return if path == "/api/state": sids = qs.get("session", []) if not sids: self._send(400, "application/json", b'{"error":"missing session"}') return state = _get_state(sids[0]) self._send(200, "application/json", json.dumps(state, default=str).encode()) return if path == "/api/stream": sids = qs.get("session", []) if not sids: self._send(400, "text/plain", b"missing session") return sid_val = sids[0] self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "keep-alive") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() q = Queue() with _sse_lock: _sse.setdefault(sid_val, []).append(q) try: while True: try: msg = q.get(timeout=15) self.wfile.write(f"data: {json.dumps(msg)}\n\n".encode()) self.wfile.flush() except Empty: self.wfile.write(b": ping\n\n") self.wfile.flush() except Exception: pass finally: with _sse_lock: subs = _sse.get(sid_val, []) if q in subs: subs.remove(q) return if path == "/api/sessions": with _sessions_lock: data = list(_sessions.keys()) self._send(200, "application/json", json.dumps(data).encode()) return self._send(404, "text/plain", b"not found") def do_POST(self): _last_req[0] = time.time() if self.path == "/event": length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) if length else b"{}" try: data = json.loads(body) sid_val = data.get("session_id", "") if sid_val: with _cache_lock: _cache.pop(sid_val, None) _push_sse(sid_val, {"type": "refresh"}) except Exception: pass self._send(200, "application/json", b'{"ok":true}') return self._send(404, "text/plain", b"not found") def do_OPTIONS(self): self.send_response(204) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() # ── Server process ───────────────────────────────────────────────────────────── def _run_server(port: int): _load_sessions() _write_pid(os.getpid(), port) threading.Thread(target=_wal_reaper, daemon=True).start() threading.Thread(target=_watchdog, daemon=True).start() httpd = ThreadingHTTPServer(("127.0.0.1", port), _Handler) httpd.serve_forever() def _spawn_daemon(port: int): log = str(_log_file()) if os.name == "posix": pid = os.fork() if pid > 0: return # parent os.setsid() pid2 = os.fork() if pid2 > 0: os._exit(0) # intermediate child # Grandchild (daemon): redirect stdio dev = open(os.devnull, "rb") logf = open(log, "a") os.dup2(dev.fileno(), 0) os.dup2(logf.fileno(), 1) os.dup2(logf.fileno(), 2) _run_server(port) os._exit(0) else: import subprocess DETACHED = 0x00000008 NEW_GRP = 0x00000200 NO_WIN = 0x08000000 logf = open(log, "a") subprocess.Popen( [sys.executable, __file__, "--run-server", str(port)], creationflags=DETACHED | NEW_GRP | NO_WIN, stdout=logf, stderr=logf, stdin=subprocess.DEVNULL, close_fds=True, ) # ── Modes ────────────────────────────────────────────────────────────────────── def _maybe_open_browser(session_id: str, port: int): url = f"http://127.0.0.1:{port}/" if session_id: url += f"?session={session_id}" if os.environ.get("CONTEXT_TIMELINE_NO_BROWSER") == "1": print(f"[context-timeline] Dashboard: {url}", file=sys.stderr) else: webbrowser.open(url, new=0, autoraise=False) def mode_server_start(): try: payload = json.load(sys.stdin) except Exception: payload = {} session_id = payload.get("session_id", "") cwd = payload.get("cwd") or os.getcwd() transcript = payload.get("transcript_path") if session_id: _register_session(session_id, cwd, transcript) pid, port = _read_pid() if _is_alive(pid): _maybe_open_browser(session_id, port) return _remove_pidfile() port = _pick_port() _spawn_daemon(port) if _wait_for_port(port, timeout=5.0): _maybe_open_browser(session_id, port) else: print(f"[context-timeline] Server failed to start. Log: {_log_file()}", file=sys.stderr) def mode_event(event_name: str): try: payload = json.load(sys.stdin) except Exception: return session_id = payload.get("session_id", "") if not session_id: return enriched = {**payload, "event": event_name, "received_at": time.time()} # WAL (survive POST failure) try: ef = _events_dir() / f"{session_id}.jsonl" with open(ef, "a") as f: f.write(json.dumps(enriched) + "\n") except Exception: pass # Best-effort notify server _, port = _read_pid() try: data = json.dumps(enriched).encode() req = urllib.request.Request( f"http://127.0.0.1:{port}/event", data=data, headers={"Content-Type": "application/json"}, method="POST", ) urllib.request.urlopen(req, timeout=0.2) except Exception: pass def mode_shutdown(): pid, _ = _read_pid() if _is_alive(pid): try: os.kill(pid, signal.SIGTERM) except Exception: pass for _ in range(25): time.sleep(0.1) if not _is_alive(pid): break if _is_alive(pid): try: os.kill(pid, signal.SIGKILL) except Exception: pass _remove_pidfile() print("[context-timeline] Shutdown complete.", file=sys.stderr) # ── Main ─────────────────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser(description="context-timeline") ap.add_argument("--server-start", action="store_true") ap.add_argument("--event", metavar="NAME") ap.add_argument("--shutdown", action="store_true") ap.add_argument("--run-server", metavar="PORT", type=int) args = ap.parse_args() if args.server_start: mode_server_start() elif args.event: mode_event(args.event) elif args.shutdown: mode_shutdown() elif args.run_server: _run_server(args.run_server) else: ap.print_help() if __name__ == "__main__": main()