diff --git a/hooks/session-end-log/README.md b/hooks/session-end-log/README.md new file mode 100644 index 0000000..21120ee --- /dev/null +++ b/hooks/session-end-log/README.md @@ -0,0 +1,51 @@ +# session-end-log + +Claude Code SessionEnd hook:每次会话结束时在 `~/.claude/log.md` 追加一行元数据 + token 用量。 + +用 Python 编写,零依赖(Ubuntu 自带 `python3`)。 + +## 输出格式 + +``` +- 2026-04-21T00:55:30 | session=353cbdc1 | cwd=/home/charles | reason=clear | tokens=total:592.7k in:39 out:3.7k cache_r:558.2k cache_w:30.7k +``` + +字段: +- `reason`:`clear` / `logout` / `resume` / `prompt_input_exit` 之一 +- `session`:session_id 前 8 位 +- `tokens`:读取该会话的 transcript(`~/.claude/projects//.jsonl`)按 `type=assistant` 的 usage 累加;transcript 读不到时降级为 `tokens=unavailable`,不阻塞会话退出 + +## 安装 + +```bash +mkdir -p ~/.claude/hooks +cp hooks/session-end-log/session-end-log.py ~/.claude/hooks/ +chmod +x ~/.claude/hooks/session-end-log.py +``` + +然后在 `~/.claude/settings.json` 的 `hooks` 下加入(与现有 `Stop` 等共存): + +```json +{ + "hooks": { + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ~/.claude/hooks/session-end-log.py 2>/dev/null || true" + } + ] + } + ] + } +} +``` + +## 测试 + +```bash +echo '{"cwd":"/home/charles","reason":"test","session_id":""}' \ + | python3 ~/.claude/hooks/session-end-log.py +tail -1 ~/.claude/log.md +``` diff --git a/hooks/session-end-log/session-end-log.py b/hooks/session-end-log/session-end-log.py new file mode 100755 index 0000000..f40738a --- /dev/null +++ b/hooks/session-end-log/session-end-log.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""SessionEnd hook: append one metadata + token-usage line to ~/.claude/log.md.""" +import json, sys, pathlib +from datetime import datetime + +try: + data = json.load(sys.stdin) +except Exception: + data = {} + +ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") +cwd = data.get("cwd", "?") +reason = data.get("reason", "?") +sid = data.get("session_id") or "?" +sid_short = sid[:8] + + +def find_transcript(): + tp = data.get("transcript_path") + if tp and pathlib.Path(tp).exists(): + return pathlib.Path(tp) + if cwd == "?" or sid == "?": + return None + slug = cwd.replace("/", "-") + candidate = pathlib.Path.home() / ".claude" / "projects" / slug / f"{sid}.jsonl" + return candidate if candidate.exists() else None + + +def sum_tokens(path): + totals = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0} + try: + with path.open() as f: + for line in f: + try: + rec = json.loads(line) + except Exception: + continue + if rec.get("type") != "assistant": + continue + u = rec.get("message", {}).get("usage") or {} + totals["input"] += u.get("input_tokens", 0) + totals["output"] += u.get("output_tokens", 0) + totals["cache_read"] += u.get("cache_read_input_tokens", 0) + totals["cache_write"] += u.get("cache_creation_input_tokens", 0) + except Exception: + pass + return totals + + +def fmt(n): + if n >= 1_000_000: + return f"{n/1_000_000:.1f}M" + if n >= 1_000: + return f"{n/1_000:.1f}k" + return str(n) + + +tpath = find_transcript() +if tpath: + t = sum_tokens(tpath) + total = sum(t.values()) + tok_str = ( + f"tokens=total:{fmt(total)} " + f"in:{fmt(t['input'])} out:{fmt(t['output'])} " + f"cache_r:{fmt(t['cache_read'])} cache_w:{fmt(t['cache_write'])}" + ) +else: + tok_str = "tokens=unavailable" + +line = f"- {ts} | session={sid_short} | cwd={cwd} | reason={reason} | {tok_str}\n" +log = pathlib.Path.home() / ".claude" / "log.md" +if not log.exists(): + log.write_text("# Session Log\n\n") +with log.open("a") as f: + f.write(line)