Add session-end-log hook with per-session token tally
既有 Stop 钩子 token-stats-hook 记的是跨会话累计快照, 无法区分"这次会话 用了多少"; session-end-log 在 SessionEnd 时按 session_id 解析本次 transcript 做单会话统计, 产出一行可回溯的索引写入 ~/.claude/log.md, 方便事后盘点 哪场会话烧了哪些 token。Python 写零依赖, Ubuntu 自带 python3 免构建。
This commit is contained in:
51
hooks/session-end-log/README.md
Normal file
51
hooks/session-end-log/README.md
Normal file
@@ -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/<slug>/<sid>.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":"<real-session-id>"}' \
|
||||||
|
| python3 ~/.claude/hooks/session-end-log.py
|
||||||
|
tail -1 ~/.claude/log.md
|
||||||
|
```
|
||||||
75
hooks/session-end-log/session-end-log.py
Executable file
75
hooks/session-end-log/session-end-log.py
Executable file
@@ -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)
|
||||||
Reference in New Issue
Block a user