#!/usr/bin/env python3 """chktime - Claude Code のタスク(セッション)ごとのやりとりの時刻を一覧表示するローカルWebアプリ. ~/.claude/projects//.jsonl を解析し、 * プロジェクト一覧 * セッション(タスク)一覧 … 開始/終了時刻・所要時間・ターン数 * ターン(ユーザー発言→Claude応答)ごとの時刻・初回応答までの待ち・処理時間・空き時間 を JSON API + ブラウザUI で提供する。標準ライブラリのみで動作する。 python chktime.py # http://127.0.0.1:8787 を開く python chktime.py --port 9000 --no-browser """ from __future__ import annotations import argparse import csv import errno import io import json import os import re import select import socket import threading import time import webbrowser from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse APP_DIR = Path(__file__).resolve().parent WEB_DIR = APP_DIR / "web" TS_RE = re.compile(rb'"timestamp":"([^"]+)"') SYSTEM_REMINDER_RE = re.compile(r".*?", re.S) TAG_RE = re.compile(r"<([a-z-]+)>(.*?)", re.S) PROMPT_LIMIT = 4000 SUMMARY_LIMIT = 200 WATCH_POLL = 5.0 # SSE: 切断を見に行く間隔(秒) PING_INTERVAL = 20.0 # SSE: 接続維持のコメントを送る間隔(秒) # ---------------------------------------------------------------- 解析ロジック def parse_ts(value): """ISO8601(UTC) 文字列 -> epoch 秒(float)。失敗したら None。""" if not value: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() except ValueError: return None def iso(epoch): if epoch is None: return None return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") def clean_text(text): """system-reminder などノイズを落とした本文を返す。""" text = SYSTEM_REMINDER_RE.sub("", text or "") return text.strip() def classify_user(obj): """user レコードを人間の発言として解釈する。対象外なら None。 戻り値: (kind, text) kind: prompt / command / interrupt / resume """ if obj.get("isMeta") or obj.get("isSidechain"): return None origin = obj.get("origin") if isinstance(origin, dict) and origin.get("kind") not in (None, "human"): return None content = (obj.get("message") or {}).get("content") extras = [] if isinstance(content, str): text = content elif isinstance(content, list): parts = [] for block in content: if not isinstance(block, dict): continue btype = block.get("type") if btype == "tool_result": return None # ツール実行結果 = 人間の発言ではない if btype == "text": parts.append(block.get("text") or "") elif btype in ("image", "document"): extras.append(btype) text = "\n".join(parts) else: return None text = clean_text(text) if text.startswith("[Request interrupted"): return ("interrupt", text) if "" in text: return None if text.startswith("Caveat: The messages below were generated by the user while running"): return None if "" in text: tags = dict(TAG_RE.findall(text)) name = (tags.get("command-name") or "").strip() args = (tags.get("command-args") or "").strip() if not name: return None return ("command", (name + " " + args).strip()) if text.startswith("This session is being continued from a previous conversation"): return ("resume", "(前の会話からの継続 / コンパクト後の再開)") if extras: text = (text + " " if text else "") + "[" + "+".join(extras) + "]" if not text: return None return ("prompt", text) def new_turn(index, kind, text, ts): return { "i": index, "kind": kind, "prompt": text[:PROMPT_LIMIT], "truncated": len(text) > PROMPT_LIMIT, "ts": ts, "firstReplyTs": None, "endTs": ts, "assistant": 0, "toolCalls": 0, "tools": {}, "sidechain": 0, "interrupted": False, "models": [], } TAIL_BYTES = 1024 # 追記の続きかを確かめるために覚えておく末尾のバイト数 class SessionParser: """1セッションの jsonl を解析する。追記されたぶんだけ読み足せる。 read() は「改行で終わっている行」までしか消費しないので、書きかけの行が あっても次回の read() で読み直される。ログは追記されるだけという前提だが、 書き換えられた場合は resumable() が False を返すので作り直せばよい。 """ def __init__(self, path: Path): self.path = path self.turns = [] self.titles = {} self.meta = {} self.counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0} self.start = None self.end = None self.pos = 0 # 解析済みのバイト位置 self.head = b"" # 先頭 TAIL_BYTES バイト self.tail = b"" # pos の直前 TAIL_BYTES バイト # -- 追記の読み取り ------------------------------------------------ def read(self, size=None): """未読部分を読んで解析する。前回の続きでなければ False を返す。 追記されるだけのログを前提に、先頭と解析済み末尾のバイト列だけを見て 「続き」かを判定する。ファイル open は Windows では数 ms かかるので、 確認と読み取りは 1 回の open で済ませる。 """ if size is not None and size < self.pos: return False # 縮んだ = 書き換えられた with self.path.open("rb") as fh: if self.pos: if fh.read(len(self.head)) != self.head: return False fh.seek(self.pos - len(self.tail)) if fh.read(len(self.tail)) != self.tail: return False else: fh.seek(0) chunk = fh.read() if not chunk: return True lines = chunk.split(b"\n") partial = lines.pop() # 改行で終わっていなければ書きかけの行 used = len(chunk) - len(partial) for raw in lines: self.feed(raw) if used: if len(self.head) < TAIL_BYTES: self.head = (self.head + chunk[:used])[:TAIL_BYTES] self.tail = (self.tail + chunk[:used])[-TAIL_BYTES:] self.pos += used return True # -- 1行の解析 ---------------------------------------------------- def bump(self, ts): self.start = ts if self.start is None else min(self.start, ts) self.end = ts if self.end is None else max(self.end, ts) def extend_last(self, ts): """直前のターンの終端を伸ばす (サブエージェント・ツール結果・中断)。""" if self.turns and ts is not None: last = self.turns[-1] last["endTs"] = max(last["endTs"] or ts, ts) def feed(self, raw): raw = raw.strip() if not raw: return # tool_result 行は巨大なので JSON パースせず時刻だけ拾う if b'"tool_result"' in raw and b'"type":"user"' in raw: match = TS_RE.search(raw) ts = parse_ts(match.group(1).decode("utf-8")) if match else None self.counts["toolResult"] += 1 if ts is not None: self.bump(ts) self.extend_last(ts) return try: obj = json.loads(raw) except (ValueError, UnicodeDecodeError): return rtype = obj.get("type") if rtype == "custom-title": self.titles["custom"] = obj.get("customTitle") return if rtype == "ai-title": self.titles["ai"] = obj.get("aiTitle") return if rtype == "last-prompt": self.titles["last"] = obj.get("lastPrompt") return ts = parse_ts(obj.get("timestamp")) if ts is not None: self.bump(ts) for key in ("cwd", "gitBranch", "version", "slug", "sessionId"): if not self.meta.get(key) and obj.get(key): self.meta[key] = obj[key] if rtype == "user": self.counts["user"] += 1 parsed = classify_user(obj) if not parsed: if obj.get("isSidechain"): self.counts["sidechain"] += 1 if self.turns and ts is not None: self.turns[-1]["sidechain"] += 1 self.extend_last(ts) return kind, text = parsed if kind == "interrupt": if self.turns: self.turns[-1]["interrupted"] = True self.extend_last(ts) return self.turns.append(new_turn(len(self.turns) + 1, kind, text, ts)) return if rtype == "assistant": if obj.get("isSidechain"): self.counts["sidechain"] += 1 if self.turns and ts is not None: self.turns[-1]["sidechain"] += 1 self.extend_last(ts) return self.counts["assistant"] += 1 if not self.turns: return turn = self.turns[-1] turn["assistant"] += 1 if ts is not None: if turn["firstReplyTs"] is None: turn["firstReplyTs"] = ts turn["endTs"] = max(turn["endTs"] or ts, ts) model = (obj.get("message") or {}).get("model") if model and model not in turn["models"]: turn["models"].append(model) content = (obj.get("message") or {}).get("content") if isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "tool_use": name = block.get("name") or "?" turn["tools"][name] = turn["tools"].get(name, 0) + 1 turn["toolCalls"] += 1 return # その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。 # 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。 # -- 結果 ---------------------------------------------------------- def result(self, stat=None): """現在までの解析結果を dict にする。派生値はここで計算し直す。""" stat = stat or self.path.stat() turns = self.turns prev_end = None active = 0.0 tool_total = 0 for turn in turns: ts = turn["ts"] turn["waitMs"] = ms(turn["firstReplyTs"], ts) turn["durMs"] = ms(turn["endTs"], ts) turn["idleMs"] = ms(ts, prev_end) turn["tsIso"] = iso(ts) turn["endIso"] = iso(turn["endTs"]) turn["summary"] = one_line(turn["prompt"], SUMMARY_LIMIT) turn["toolList"] = sorted(turn["tools"].items(), key=lambda kv: (-kv[1], kv[0])) tool_total += turn["toolCalls"] if turn["durMs"]: active += turn["durMs"] prev_end = turn["endTs"] if turn["endTs"] is not None else prev_end titles = self.titles first_prompt = next((t["prompt"] for t in turns if t["kind"] == "prompt"), "") title = ( titles.get("custom") or titles.get("ai") or one_line(first_prompt, 120) or titles.get("last") or self.meta.get("slug") or self.path.stem ) return { "id": self.path.stem, "file": str(self.path), "title": title, "titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"), "cwd": self.meta.get("cwd"), "gitBranch": self.meta.get("gitBranch"), "version": self.meta.get("version"), "slug": self.meta.get("slug"), "start": iso(self.start), "end": iso(self.end), "startEpoch": self.start, "endEpoch": self.end, "wallMs": ms(self.end, self.start), "activeMs": int(active) if active else 0, "turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")), "userMsgs": self.counts["user"], "assistantMsgs": self.counts["assistant"], "toolResults": self.counts["toolResult"], "sidechainMsgs": self.counts["sidechain"], "toolCalls": tool_total, "bytes": stat.st_size, "mtime": stat.st_mtime, "turns": turns, } def parse_file(path: Path): """1セッションの jsonl を頭から解析して dict を返す。""" parser = SessionParser(path) parser.read() return parser.result() def ms(later, earlier): if later is None or earlier is None: return None return int(round((later - earlier) * 1000)) def one_line(text, limit): text = " ".join((text or "").split()) return text[:limit] + ("…" if len(text) > limit else "") def summary_of(session): out = {k: v for k, v in session.items() if k != "turns"} turns = session["turns"] out["firstPrompt"] = one_line(next((t["prompt"] for t in turns if t["kind"] == "prompt"), ""), SUMMARY_LIMIT) out["lastPrompt"] = one_line(turns[-1]["prompt"] if turns else "", SUMMARY_LIMIT) models = [] for turn in turns: for model in turn["models"]: if model not in models: models.append(model) out["models"] = models return out # ---------------------------------------------------------------- ストア class Entry: """1ファイルぶんのキャッシュ。パーサを持ち回して追記分だけ読み足す。""" __slots__ = ("lock", "parser", "token", "data") def __init__(self, path: Path): self.lock = threading.Lock() # 同じファイルを同時に解析しない self.parser = SessionParser(path) self.token = None # (mtime, size) self.data = None class Store: """jsonl の解析結果を (mtime, size) でキャッシュする。 変化したファイルは、末尾に追記されただけなら追加分だけを解析する。 書き換えられていた場合 (縮んだ / 途中が変わった) は頭から読み直す。 """ def __init__(self, root: Path): self.root = root self._cache = {} self._lock = threading.Lock() def projects(self): items = [] if not self.root.is_dir(): return items for entry in sorted(self.root.iterdir()): if not entry.is_dir(): continue files = list(entry.glob("*.jsonl")) if not files: continue stats = [f.stat() for f in files] newest = max(files, key=lambda f: f.stat().st_mtime) items.append( { "id": entry.name, "path": sniff_cwd(newest) or decode_project_dir(entry.name), "sessions": len(files), "bytes": sum(s.st_size for s in stats), "lastActivity": iso(max(s.st_mtime for s in stats)), "lastEpoch": max(s.st_mtime for s in stats), } ) items.sort(key=lambda p: p["lastEpoch"], reverse=True) return items def session_files(self, project_id): directory = self.root / project_id if not directory.is_dir() or os.path.sep in project_id or project_id in ("..", "."): raise FileNotFoundError(project_id) return sorted(directory.glob("*.jsonl")) def session(self, path: Path): key = str(path) with self._lock: entry = self._cache.get(key) if entry is None: entry = self._cache[key] = Entry(path) with entry.lock: stat = path.stat() token = (stat.st_mtime, stat.st_size) if entry.token == token and entry.data is not None: return entry.data if not entry.parser.read(stat.st_size): entry.parser = SessionParser(path) # 書き換えられていた: 頭から entry.parser.read() entry.data = entry.parser.result(stat) entry.token = token return entry.data def sessions(self, project_id): out = [summary_of(self.session(p)) for p in self.session_files(project_id)] out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True) return out def one(self, project_id, session_id): for path in self.session_files(project_id): if path.stem == session_id: return self.session(path) raise FileNotFoundError(session_id) def all_sessions(self): """全プロジェクトのタスクを1つのリストにまとめる (開始が新しい順)。""" out = [] for project in self.projects(): for path in self.session_files(project["id"]): summary = summary_of(self.session(path)) summary["project"] = project["id"] summary["projectPath"] = summary.get("cwd") or project["path"] out.append(summary) out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True) return out def daily_stats(self, project_id=None, days=30): """ターンをローカル日付で集計する。project_id=None なら全プロジェクト。 days>0 なら「今日から遡って days 日」、それ以外は全期間。 活動のない日も 0 の行として埋めるので、そのまま横軸に使える。 """ paths = {p["id"]: p["path"] for p in self.projects()} if project_id: if project_id not in paths: raise FileNotFoundError(project_id) targets = [project_id] else: targets = list(paths) today = datetime.now().date() cutoff = today - timedelta(days=days - 1) if days and days > 0 else None per_day = {} per_project = {} seen = set() for pid in targets: for path in self.session_files(pid): session = self.session(path) for turn in session["turns"]: if turn["ts"] is None: continue day = datetime.fromtimestamp(turn["ts"]).date() if cutoff and day < cutoff: continue dur = turn["durMs"] or 0 counted = 1 if turn["kind"] in ("prompt", "command") else 0 bucket = per_day.setdefault(day, {"activeMs": 0, "turns": 0, "sessions": set(), "byProject": {}}) bucket["activeMs"] += dur bucket["turns"] += counted bucket["sessions"].add(session["id"]) bucket["byProject"][pid] = bucket["byProject"].get(pid, 0) + dur total = per_project.setdefault(pid, {"activeMs": 0, "turns": 0, "sessions": set()}) total["activeMs"] += dur total["turns"] += counted total["sessions"].add(session["id"]) seen.add(session["id"]) if per_day: start = cutoff or min(per_day) end = max(max(per_day), today) elif cutoff: start, end = cutoff, today # データなしでも指定範囲は 0 で埋める else: start = end = None rows = [] cur = start while cur and cur <= end: bucket = per_day.get(cur) rows.append( { "date": cur.isoformat(), "dow": cur.weekday(), "activeMs": bucket["activeMs"] if bucket else 0, "turns": bucket["turns"] if bucket else 0, "sessions": len(bucket["sessions"]) if bucket else 0, "byProject": bucket["byProject"] if bucket else {}, } ) cur += timedelta(days=1) projects = sorted( ( { "id": pid, "path": paths.get(pid, pid), "activeMs": v["activeMs"], "turns": v["turns"], "sessions": len(v["sessions"]), } for pid, v in per_project.items() ), key=lambda p: -p["activeMs"], ) busiest = max(rows, key=lambda r: r["activeMs"], default=None) return { "scope": project_id or "", "rangeDays": days if days and days > 0 else 0, "days": rows, "projects": projects, "totals": { "activeMs": sum(r["activeMs"] for r in rows), "turns": sum(r["turns"] for r in rows), "sessions": len(seen), "activeDays": sum(1 for r in rows if r["activeMs"] or r["turns"]), "busiestDate": busiest["date"] if busiest and busiest["activeMs"] else None, "busiestMs": busiest["activeMs"] if busiest else 0, }, } def timeline(self, project_id=None, days=7): """ガント表示用。セッションを1本のバーとして、内側に応答中の区間を返す。 spans は各ターンの [発言時刻, 応答完了] を、隙間 1 秒以内なら繋げたもの。 """ paths = {p["id"]: p["path"] for p in self.projects()} if project_id: if project_id not in paths: raise FileNotFoundError(project_id) targets = [project_id] else: targets = list(paths) now = datetime.now().timestamp() t0 = None if days and days > 0: midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) t0 = (midnight - timedelta(days=days - 1)).timestamp() rows = [] per_project = {} for pid in targets: for path in self.session_files(pid): session = self.session(path) start, end = session["startEpoch"], session["endEpoch"] if start is None: continue if t0 is not None and (end if end is not None else start) < t0: continue spans = [] active = 0 for turn in session["turns"]: a = turn["ts"] if a is None: continue b = turn["endTs"] if turn["endTs"] is not None else a if t0 is not None and b < t0: continue active += turn["durMs"] or 0 if spans and a - spans[-1][1] <= 1: spans[-1][1] = max(spans[-1][1], b) else: spans.append([a, b]) rows.append( { "id": session["id"], "project": pid, "projectPath": session.get("cwd") or paths.get(pid, pid), "title": session["title"], "start": session["start"], "end": session["end"], "startEpoch": round(start, 1), "endEpoch": round(end if end is not None else start, 1), "wallMs": session["wallMs"], "activeMs": active, "turnCount": session["turnCount"], "toolCalls": session["toolCalls"], "spans": [[round(a, 1), round(b, 1)] for a, b in spans], } ) total = per_project.setdefault(pid, {"activeMs": 0, "sessions": 0}) total["activeMs"] += active total["sessions"] += 1 rows.sort(key=lambda s: s["startEpoch"]) if t0 is None: t0 = min((s["startEpoch"] for s in rows), default=now) t1 = max([now] + [s["endEpoch"] for s in rows]) projects = sorted( ( {"id": pid, "path": paths.get(pid, pid), "activeMs": v["activeMs"], "sessions": v["sessions"]} for pid, v in per_project.items() ), key=lambda p: -p["activeMs"], ) return { "scope": project_id or "", "rangeDays": days if days and days > 0 else 0, "from": round(t0, 1), "to": round(t1, 1), "now": round(now, 1), "sessions": rows, "projects": projects, } def clear(self): with self._lock: self._cache.clear() class Watcher(threading.Thread): """projects 配下の jsonl を定期的に stat して変更を検出する (自動追従の通知用)。 ファイル数・合計サイズ・最新 mtime の組を「版」として持ち、変わったら rev を進めて 待っているクライアント (SSE) を起こす。解析結果は Store 側が (mtime, size) で キャッシュしているので、ここでは通知だけを担う。 """ def __init__(self, root: Path, interval=2.0): super().__init__(daemon=True) self.root = root self.interval = max(0.5, interval) self.rev = 0 self.newest = None # (project_id, session_id) self._cv = threading.Condition() self._token, self.newest = self._scan() def _scan(self): files = 0 total = 0 newest_mtime = 0.0 newest = None if self.root.is_dir(): for entry in self.root.iterdir(): if not entry.is_dir(): continue for path in entry.glob("*.jsonl"): try: stat = path.stat() except OSError: continue files += 1 total += stat.st_size if stat.st_mtime > newest_mtime: newest_mtime = stat.st_mtime newest = (entry.name, path.stem) return (files, total, round(newest_mtime, 3)), newest def run(self): while True: time.sleep(self.interval) try: token, newest = self._scan() except OSError: continue with self._cv: if token != self._token: self._token = token self.newest = newest self.rev += 1 self._cv.notify_all() def wait_change(self, rev, timeout): """rev より進むまで待って、現在の rev を返す (タイムアウト時は同じ値)。""" with self._cv: if self.rev != rev: return self.rev self._cv.wait(timeout) return self.rev def info(self): with self._cv: project, session = self.newest or (None, None) return {"rev": self.rev, "project": project, "session": session, "interval": self.interval} CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"') def sniff_cwd(path: Path, max_lines=200): """jsonl の先頭から cwd を拾う (プロジェクトの実パス表示用)。""" try: with path.open("rb") as fh: for _, raw in zip(range(max_lines), fh): match = CWD_RE.search(raw) if match: return json.loads(b'"' + match.group(1) + b'"') except OSError: pass return None def decode_project_dir(name): """'C--debug-ImgChkSystem' -> 'C:\\debug\\ImgChkSystem' (推定)。""" if len(name) > 3 and name[1:3] == "--" and name[0].isalpha(): return name[0].upper() + ":\\" + name[3:].replace("-", "\\") return name.replace("-", "/") # ---------------------------------------------------------------- HTTP def fmt_dur(msec): if msec is None: return "" seconds = msec / 1000.0 if seconds < 60: return f"{seconds:.1f}s" minutes, seconds = divmod(int(round(seconds)), 60) if minutes < 60: return f"{minutes}m{seconds:02d}s" hours, minutes = divmod(minutes, 60) return f"{hours}h{minutes:02d}m" def parse_days(value): """?days= の解釈。'all' / 0 / 不正値は全期間 (0) とみなす。""" try: days = int(value) except (TypeError, ValueError): return 0 return max(0, min(days, 3650)) def sessions_csv(sessions, with_project=False): buf = io.StringIO(newline="") writer = csv.writer(buf) head = ["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"] writer.writerow((["project"] if with_project else []) + head) for s in sessions: writer.writerow( ([s.get("projectPath") or s.get("project") or ""] if with_project else []) + [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]), fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""] ) return buf.getvalue() def daily_csv(data): buf = io.StringIO(newline="") writer = csv.writer(buf) projects = data["projects"] writer.writerow(["date", "dow", "active", "active_ms", "turns", "sessions"] + [p["path"] for p in projects]) labels = "月火水木金土日" for row in data["days"]: writer.writerow( [row["date"], labels[row["dow"]], fmt_dur(row["activeMs"]), row["activeMs"], row["turns"], row["sessions"]] + [fmt_dur(row["byProject"].get(p["id"])) for p in projects] ) return buf.getvalue() def turns_csv(session): buf = io.StringIO(newline="") writer = csv.writer(buf) writer.writerow(["#", "kind", "time", "end", "first_reply", "duration", "idle_before", "assistant_msgs", "tool_calls", "prompt"]) for t in session["turns"]: writer.writerow( [t["i"], t["kind"], t["tsIso"] or "", t["endIso"] or "", fmt_dur(t["waitMs"]), fmt_dur(t["durMs"]), fmt_dur(t["idleMs"]), t["assistant"], t["toolCalls"], one_line(t["prompt"], 500)] ) return buf.getvalue() def timeline_csv(data): buf = io.StringIO(newline="") writer = csv.writer(buf) writer.writerow(["project", "session_id", "title", "start", "end", "wall", "active", "turns", "tool_calls"]) for s in data["sessions"]: writer.writerow( [s["projectPath"], s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]), fmt_dur(s["activeMs"]), s["turnCount"], s["toolCalls"]] ) return buf.getvalue() class Server(ThreadingHTTPServer): # Windows では SO_REUSEADDR が「使用中のポートにも bind できる」意味になり、 # 二重起動が検出できなくなるため無効にする。 allow_reuse_address = False daemon_threads = True class Handler(BaseHTTPRequestHandler): server_version = "chktime" store: Store = None # set on the server class def log_message(self, fmt, *args): # 静かに pass def do_GET(self): parsed = urlparse(self.path) route = parsed.path query = parse_qs(parsed.query) try: if route.startswith("/api/"): return self.api(route, query) return self.static(route) except FileNotFoundError as exc: self.send_json({"error": f"not found: {exc}"}, 404) except Exception as exc: # noqa: BLE001 - UI にエラーを返す self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500) def api(self, route, query): store = self.server.store if route == "/api/projects": return self.send_json({"root": str(store.root), "projects": store.projects()}) if route == "/api/sessions": project = query.get("project", [""])[0] sessions = store.sessions(project) if query.get("format", [""])[0] == "csv": return self.send_text(sessions_csv(sessions), "text/csv", f"{project}-sessions.csv") return self.send_json({"project": project, "sessions": sessions}) if route == "/api/all": sessions = store.all_sessions() if query.get("format", [""])[0] == "csv": return self.send_text(sessions_csv(sessions, True), "text/csv", "all-sessions.csv") return self.send_json({"project": "", "sessions": sessions}) if route == "/api/daily": project = query.get("project", [""])[0] data = store.daily_stats(project or None, parse_days(query.get("days", ["30"])[0])) if query.get("format", [""])[0] == "csv": return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv") return self.send_json(data) if route == "/api/timeline": project = query.get("project", [""])[0] data = store.timeline(project or None, parse_days(query.get("days", ["7"])[0])) if query.get("format", [""])[0] == "csv": return self.send_text(timeline_csv(data), "text/csv", f"{project or 'all'}-timeline.csv") return self.send_json(data) if route == "/api/session": project = query.get("project", [""])[0] session_id = query.get("id", [""])[0] session = store.one(project, session_id) if query.get("format", [""])[0] == "csv": return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv") return self.send_json(session) if route == "/api/reload": store.clear() return self.send_json({"ok": True}) if route == "/api/rev": return self.send_json(self.server.watcher.info()) if route == "/api/watch": return self.watch() raise FileNotFoundError(route) def watch(self): """SSE。ログに変更があるまでブロックし、あれば change イベントを流す。 タブを閉じた接続はソケットの EOF で気付いて抜ける。書き込みが失敗する まで (ping 最大20秒ぶん) スレッドを抱えたままにしないため。 """ watcher = self.server.watcher self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Cache-Control", "no-store") self.send_header("X-Accel-Buffering", "no") self.end_headers() rev = watcher.rev last_ping = time.monotonic() try: self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n") while not self.disconnected(): current = watcher.wait_change(rev, WATCH_POLL) if current != rev: rev = current self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n") last_ping = time.monotonic() elif time.monotonic() - last_ping >= PING_INTERVAL: self.stream(": ping\n\n") # 接続維持 last_ping = time.monotonic() except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError): return # タブを閉じた等 def disconnected(self): """クライアントが切断済みかを見る (受信側の EOF で判定)。""" try: ready, _, _ = select.select([self.connection], [], [], 0) return bool(ready) and not self.connection.recv(1, socket.MSG_PEEK) except (OSError, ValueError): return True def stream(self, text): self.wfile.write(text.encode("utf-8")) self.wfile.flush() def static(self, route): name = "index.html" if route in ("/", "") else route.lstrip("/") target = (WEB_DIR / name).resolve() if not str(target).startswith(str(WEB_DIR)) or not target.is_file(): raise FileNotFoundError(name) ctype = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", }.get(target.suffix, "application/octet-stream") body = target.read_bytes() self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def send_json(self, payload, status=200): body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def send_text(self, text, ctype, filename): body = ("\ufeff" + text).encode("utf-8") # Excel 対応の BOM self.send_response(200) self.send_header("Content-Type", f"{ctype}; charset=utf-8") self.send_header("Content-Disposition", f'attachment; filename="{filename}"') self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def main(): parser = argparse.ArgumentParser(description="Claude Code のタスク別やりとり時刻ビューア") parser.add_argument("--port", type=int, default=8787) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ") parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)") parser.add_argument("--no-browser", action="store_true") args = parser.parse_args() root = Path(args.dir).expanduser() if not root.is_dir(): raise SystemExit(f"見つかりません: {root}") url = f"http://{args.host}:{args.port}/" try: server = Server((args.host, args.port), Handler) except OSError as exc: if exc.errno not in (errno.EADDRINUSE, getattr(errno, "WSAEADDRINUSE", 10048)): raise # 既に起動済み: ブラウザだけ開いて終了する (ショートカットの二重起動対策) print(f"すでに起動しています: {url}") if not args.no_browser: webbrowser.open(url) return server.store = Store(root) server.watcher = Watcher(root, args.interval) server.watcher.start() print(f"chktime: {root}") print(f" -> {url} (Ctrl+C で終了)") if not args.no_browser: threading.Timer(0.4, lambda: webbrowser.open(url)).start() try: server.serve_forever() except KeyboardInterrupt: print("\nbye") if __name__ == "__main__": main()