Files
chktime/chktime.py
T
yoshikazuandClaude Opus 5 0205404797 タスクを時間軸に並べるガントタブを追加
薄いバーで開始〜終了の全体、濃い部分で応答が動いていた区間を重ねて描く。
放置していた時間や、セッションを並行して動かしていた時間が見える。

- /api/timeline: セッションごとに全体の範囲と応答中の区間 (spans) を返す
  (隙間 1 秒以内のターンは結合、期間指定 days= / 全期間、CSV も可)
- ガントタブ: 1日/3日/7日/14日/30日/全期間の切替、1〜3日では時刻目盛りに
  自動で切替、土日の帯・日境界線・現在時刻の点線、横断時はプロジェクト別の色
- 行のホバーで内訳、クリックでそのタスクのやりとりを開く
- ラベル列とバーの行を22pxで揃え、時間軸ヘッダは縦スクロール中も固定
- ペイン幅に応じてラベル幅を詰め、横スクロールを出さない

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:49:42 +09:00

827 lines
31 KiB
Python

#!/usr/bin/env python3
"""chktime - Claude Code のタスク(セッション)ごとのやりとりの時刻を一覧表示するローカルWebアプリ.
~/.claude/projects/<project>/<sessionId>.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 threading
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"<system-reminder>.*?</system-reminder>", re.S)
TAG_RE = re.compile(r"<([a-z-]+)>(.*?)</\1>", re.S)
PROMPT_LIMIT = 4000
SUMMARY_LIMIT = 200
# ---------------------------------------------------------------- 解析ロジック
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 "<local-command-stdout>" in text:
return None
if text.startswith("Caveat: The messages below were generated by the user while running"):
return None
if "<command-name>" 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": [],
}
def parse_file(path: Path):
"""1セッションの jsonl を解析して dict を返す。"""
turns = []
titles = {}
meta = {}
counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0}
start = end = None
pending_prompt = None # turn 開始待ち (queue-operation より後の user 本体を採用)
with path.open("rb") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
# 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
counts["toolResult"] += 1
if ts is not None:
start = ts if start is None else min(start, ts)
end = ts if end is None else max(end, ts)
if turns:
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
continue
try:
obj = json.loads(raw)
except (ValueError, UnicodeDecodeError):
continue
rtype = obj.get("type")
if rtype == "custom-title":
titles["custom"] = obj.get("customTitle")
continue
if rtype == "ai-title":
titles["ai"] = obj.get("aiTitle")
continue
if rtype == "last-prompt":
titles["last"] = obj.get("lastPrompt")
continue
ts = parse_ts(obj.get("timestamp"))
if ts is not None:
start = ts if start is None else min(start, ts)
end = ts if end is None else max(end, ts)
for key in ("cwd", "gitBranch", "version", "slug", "sessionId"):
if not meta.get(key) and obj.get(key):
meta[key] = obj[key]
if rtype == "user":
counts["user"] += 1
parsed = classify_user(obj)
if not parsed:
if obj.get("isSidechain"):
counts["sidechain"] += 1
if turns and ts is not None:
turns[-1]["sidechain"] += 1
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
continue
kind, text = parsed
if kind == "interrupt":
if turns:
turns[-1]["interrupted"] = True
if ts is not None:
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
continue
pending_prompt = None
turns.append(new_turn(len(turns) + 1, kind, text, ts))
continue
if rtype == "assistant":
if obj.get("isSidechain"):
counts["sidechain"] += 1
if turns and ts is not None:
turns[-1]["sidechain"] += 1
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
continue
counts["assistant"] += 1
if turns:
turn = 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
continue
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
# 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。
_ = pending_prompt # 予約
# ターンごとの派生値
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
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 meta.get("slug")
or path.stem
)
stat = path.stat()
return {
"id": path.stem,
"file": str(path),
"title": title,
"titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"),
"cwd": meta.get("cwd"),
"gitBranch": meta.get("gitBranch"),
"version": meta.get("version"),
"slug": meta.get("slug"),
"start": iso(start),
"end": iso(end),
"startEpoch": start,
"endEpoch": end,
"wallMs": ms(end, start),
"activeMs": int(active) if active else 0,
"turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")),
"userMsgs": counts["user"],
"assistantMsgs": counts["assistant"],
"toolResults": counts["toolResult"],
"sidechainMsgs": counts["sidechain"],
"toolCalls": tool_total,
"bytes": stat.st_size,
"mtime": stat.st_mtime,
"turns": turns,
}
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 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):
stat = path.stat()
key = str(path)
with self._lock:
hit = self._cache.get(key)
if hit and hit[0] == (stat.st_mtime, stat.st_size):
return hit[1]
parsed = parse_file(path)
with self._lock:
self._cache[key] = ((stat.st_mtime, stat.st_size), parsed)
return parsed
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()
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})
raise FileNotFoundError(route)
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("--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)
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()