给 AI 编码 Agent
装一副仪表盘
一个常驻本地的 daemon,把 Claude Code 和 Codex 的每次工具调用、每次 token 消耗、每一次会话都收集进 SQLite,再用 TUI 和 Web 两套面板回放给你看。
- 用 Claude Code / Codex 写代码,钱花在哪里、做了什么、花了多长时间——默认是黑盒。TokenMeter 把它打开。
- 两条数据源:Claude 走 hook 事件 + JSONL 日志,Codex 只能扫日志。一个 daemon 接住,统一落到 SQLite。
- 终端里
tm开 TUI 看四视图;浏览器里tm web开 Dashboard。零配置、单二进制、纯 Go。
- 读给谁
- 在用 AI 编码 agent 的开发者;想知道"花了多少、做了什么"的维护者;想理解 TokenMeter 架构的贡献者。
- 读完能答
- TokenMeter 解决什么问题、数据怎么来、怎么落库、TUI/Web 怎么读、为什么两条平台路径不一样。
- 对照原件
- README.md · CLAUDE.md · docs/architecture.svg · internal/* 源码
AI 编码的黑盒
用 Claude Code 或 Codex 的那一刻,你其实并不知道它在花多少钱、做了什么、上下文塞了多少。
跑完一个任务,唯一能看见的是终端里最后一行 "Done"。至于:
- 这次会话花了多少 token?多少钱?
- Claude 真的读了那个文件,还是只搜了关键词?
- 它 spawn 了几个 subagent?每个跑了多久?
- 上周这个项目在 AI 编码上的总支出是多少?哪个模型贵?
- 上次改错的地方,是哪次 Edit 调用?参数是什么?
这些问题,官方 UI 都不直接回答。你只能翻 ~/.claude/projects/ 下那堆 JSONL 文件,或者每次会话结束打开 Codex 的 sessions 目录一行行看。
想象你开车上高速,没有时速表、没有油表、没有行程里程。车还能开,但你不知道油还剩多少、一个月花了多少油钱、哪段路最费油。TokenMeter 就是给 AI 编码这辆车装上仪表盘。
常见场景
没有 TokenMeter
- 月底看 API 账单懵住
- 怀疑某个任务慢,但找不到是哪次 tool call
- Subagent 出错了,不知道它调了什么
- 想回忆"上次那个 prompt 是咋写的",找不到
有 TokenMeter
- 今日/本周/本月费用一眼看清
- 每个 tool 调用耗时、参数、结果都在
- Subagent 层级可折叠展开
- 按
/搜索历史 prompt
正常使用 Claude Code 或 Codex,TokenMeter 在后台自动采集——不改变你的工作流,只是把一直就发生但你看不见的事变成可见。
一个 daemon 接住所有
架构可以在一张图里讲完:左边数据源、中间 daemon + SQLite、右边 TUI 与 Web。
五块各自干什么
- 数据源:两类 AI 编码工具产出三种信号——Claude 的 hook 事件(实时)、Claude 的 JSONL 日志(token 真相)、Codex 的 JSONL 日志(唯一通道)
- Collectors · 转换层:
tm emit是 hook 的轻量接收端;ClaudeLogWatcher扫描 JSONL 补 token;CodexWatcher轮询 Codex 会话目录 - Daemon:Unix socket 接事件 → 写 SQLite → 广播给订阅者。单例,PID lock 保护
- SQLite:
~/.tokenmeter/data/tokenmeter.db,5 张表装下所有历史 - Consumers:TUI(bubbletea,4 个 tab)订阅实时事件;Web(嵌入式 SPA + REST API)独立启动
左边抓数据、中间 daemon 存库、右边两种界面展示。抓取与展示解耦——daemon 在后台安静跑着,开不开 TUI 都不影响数据持续采集。
Claude 和 Codex不一样
Claude Code 给了 hook 机制,实时、干净;Codex 没有,只能扫日志。所以 TokenMeter 为它们各自写了一条 collector。
Claude Code
Codex
Claude Code 路径:hook + 日志"双通道"
Claude Code 的每个 tool 调用都会 stdin 发一个 JSON 给 TokenMeter 注册的 hook 脚本(PreToolUse 发起前、PostToolUse 完成后)。hook 脚本就是 tm emit——把 stdin 读完,转成事件发到 Unix socket,结束。
但是——hook 本身不携带 token 数量。Claude 只在把会话写 JSONL 时附上 usage。所以 TokenMeter 同时跑一个 ClaudeLogWatcher 扫描 ~/.claude/projects/ 下那些 JSONL 文件,找 type == "assistant" 的行,从 message.usage 把 token 数补进数据库。
Hook 像服务员实时报给后台"桌 3 点了宫保鸡丁"——知道点了什么但不知道价格。真要结账,得等厨房打印小票(JSONL 日志)来对账。TokenMeter 两头都接,才能同时知道"做了什么"和"花了多少钱"。
Codex 路径:只能扫日志
Codex 没有 hook 机制。TokenMeter 启一个 CodexWatcher 轮询 ~/.codex/sessions/YYYY/MM/DD/*.jsonl。关注三种 payload:
session_meta→ 会话开始response_item→ 工具调用(function_call起、function_call_output完)event_msgwithtype:"token_count"→ token 记账
轮询意味着同一份数据可能被读两次。Codex watcher 在内存里维护 lastTokenUsage map 去重,保证不重复计费。
Codex 路径天然比 Claude 慢、粗、依赖去重。如果以后 Codex 出了 hook,这条路径可以退役;当下只能先扫日志兜底,宁可多做一次工作,也不能遗漏会话。
Claude JSONL 的一个小坑 · cwd 编码
Claude 把会话 JSONL 存在 ~/.claude/projects/<cwd-encoded>/<session-id>.jsonl。cwd 的编码规则是:每个 / 替换成 -(含首字符)。比如:
/Users/admin/code/tokenmeter→-Users-admin-code-tokenmeter
这个细节写在 internal/collector/claude.go 里——错一个字符,就找不到日志文件。
两条路,一种事件
不管数据从 Claude 还是 Codex 来,都先转成同一个 Event struct 再进 daemon。这是架构里最省事的一招。
type Event struct {
ID string // tool_use_id (Claude) 或 call_id (Codex)
Type EventType // ToolCallStart | End | AgentStart | ...
SessionID string
AgentID string
Platform Platform // "claude" | "codex"
Timestamp time.Time
Data EventData
}
为什么这样设计
Claude 和 Codex 的原生事件结构完全不一样。Claude 的 hook JSON 有 hook_event_name / session_id / tool_input / tool_use_id;Codex 的 JSONL 里是 timestamp / type / payload 套娃。
如果 daemon、storage、TUI 都分别处理两种原生格式,每加一个平台就要在五个地方改代码。所以 TokenMeter 的第一件事是把它们收敛到统一 Event,后面所有层都只认 Event。
不管你是从日本运电器、从法国运红酒、还是从美国运芯片,到了海关都得填同一张报关单。集装箱、船舱、搬运方式各不相同,但报关单格式固定。daemon 对待事件的方式一样——你从哪条路径来的,到我这都是一个 Event。
Pre / Post 关联靠 ID 对齐
一次工具调用其实是两个事件:Pre 发起前、Post 完成后。daemon 靠 Event.ID 关联两头,算出 duration_ms。
- Claude 用
tool_use_id(Claude Code 保证全局唯一) - Codex 用
call_id(函数调用 id)
一条调用 Pre 先到、Post 后到——中间可能隔几秒。daemon 维护一张待配对 map,Post 到时 pop 出来更新那行 tool_calls 记录。
未来接第三个平台(比如 Cursor、Cline 或自研 agent),只需要写一个新的 collector 把原生信号转成 Event,后面 daemon / storage / TUI / Web 全都不用改。这是"统一模型"为软件换来的弹性。
SQLite 五张表
一个 daemon 进程、一个 .db 文件、五张表。schema 变动遵循"只加不删"的兼容原则。
所有历史都落在 ~/.tokenmeter/data/tokenmeter.db,用 modernc 的纯 Go SQLite 驱动。5 张表,职责分明:
sessions
session_id PK · platform · start_time · status · total_input_tokens · total_output_tokens · total_cost_usd
agents
agent_id PK · session_id FK · parent_agent_id · role · status。subagent 靠 parent_agent_id 形成树。
tool_calls
call_id PK · agent_id / session_id FK · tool_name · params_summary · result_summary · duration_ms · status
token_usage
id AUTOINCREMENT · agent_id / session_id FK · input_tokens · output_tokens · model · cost_usd。source_id 唯一索引防重。
file_changes
id AUTOINCREMENT · session_id FK · file_path · change_type
Schema 变更原则
internal/storage/db.go 里的 migrate() 函数只用两种动作:
CREATE TABLE IF NOT EXISTS建新表addColumnIfMissing()幂等加新列
不允许删列、重命名列。老版本 TokenMeter 的数据库升级到新版本必须能直接打开用。这条纪律让 TokenMeter 敢做"零停机自升级"。
SQLite 存时间统一用 TEXT + RFC3339 格式,读的时候用 parseTime()。不用 UNIX 时间戳——跨平台调试时人眼可读更重要。
TUI 四视图
Bubbletea 做底、每 2 秒轮询 + 实时事件推送。Tab 键切换,j/k 上下移,/ 搜索。
| 视图 | 内容 | 数据来源 |
|---|---|---|
| Dashboard | 会话列表(费用、上下文占用、状态、标签);顶部汇总栏支持 t 切换今日/本周/本月/全部 |
db.ListSessions() |
| Messages | 从 Claude / Codex JSONL 提取的用户对话消息;/ 全文搜索 |
collector.ReadUserMessages() |
| Tool Calls | 当前会话的工具调用流;Enter 展开查看参数与结果 | db.ListToolCalls(session, 500) |
| Stats | 7 天费用柱状图、工具调用统计、Agent 分布、文件变更汇总 | 多张表聚合 |
刷新机制:轮询 + 推送
TUI 每 2 秒跑一次 tickCmd 主动查库(兜底),同时订阅 daemon 的实时事件(listenEvents)——有新 hook 事件一到就立刻刷屏。两条路互为保底。
显示屏既接收调度中心的实时推送(新车进站立刻改),又每 30 秒自己拉一次全表(防推送丢包)。TUI 的 tick + listenEvents 是同一套思路——实时但不依赖实时。
几个贴心细节
- gitBranch 作显示名:原始 session UUID 太丑;优先用 JSONL 顶层的
gitBranch,其次filepath.Base(cwd),最后才是 UUID - 跨视图切会话:
[/]在任何 tab 都能切上一个/下一个 session - Copy 恢复命令:
c键把当前会话的恢复命令拷到剪贴板 - 展开详情不丢:
expandedCallsmap 按 call_id 持久化,刷新后仍保留哪些调用是展开的
快捷键节选
当 TUI 不够用
想看图表、想分享给不用终端的同事、想在更大屏幕上翻旧会话——启 tm web 就好。
tm web 启动一个独立的 HTTP 服务(默认 :8370),提供:
- 费用面积图——Canvas 绘制,hover 显示详情,点击某一天按天筛选会话
- 模型占比 + 工具排行——同一屏看清钱花哪了
- 会话列表——搜索、排序、按费用色标
- 会话详情——对话消息、工具时间线、文件变更、Agent 层级
- 深色 / 浅色主题——自动跟随系统偏好
- 键盘导航——
j/k选、Enter开、/搜、←/→切、Esc返回、?帮助
架构上:独立的进程
Web 不是 TUI 的扩展,是独立进程。它直接读 SQLite(与 daemon 共用同一个 .db 文件),对外暴露 REST API + 嵌入式 SPA(Go embed 打包,不依赖外部静态目录)。
TUI 和 Web 互不知道对方存在。都读同一个 SQLite,都订阅同一套 daemon 事件。开一个不影响另一个。也可以只开 Web、不开 TUI。
Token 只算一次
daemon 重启、日志被重新扫描、Codex 轮询重叠——每个事件都可能被处理多次。去重是默默的地板工程。
三种身份键
tool_use_id
Claude Code 自己分配的全局唯一 id。Pre/Post 对齐、重复事件去重都靠它。
call_id
Codex JSONL 里的 function call id。作用同 tool_use_id。
source_id
token_usage 表上的唯一索引。INSERT OR IGNORE 保证同一条 JSONL 行扫多遍也只记一次。
三种典型重复场景
- daemon 重启——重启后
ClaudeLogWatcher从头扫 JSONL。没有source_id唯一索引,token 会被重新累加一遍。 - Codex 轮询覆盖——文件还在写,上次轮询读了 80%,这次读了 100%。内存
lastTokenUsagemap 记录"这个 key 上次用量是 X",避免重复计费。 - Stop hook 重试——Claude 有时会发两次 Stop。
MarkPendingToolCallsInterrupted幂等,跑几次都一样。
会计每月对账,同一笔钱可能在三张账单上出现。要么靠唯一凭证号对齐、要么靠"已入账"标记。TokenMeter 的 source_id 唯一索引是凭证号;lastTokenUsage map 是"已入账"标记。两种都用——保险一点。
僵尸会话的处理
有些会话因为 Claude 进程被 kill、机器休眠等原因没走 SessionEnd,数据库里就挂着 status=running。daemon 每次启动会跑 MarkStaleSessionsEnded(2h)——2 小时没更新的 running 会话,标记为 ended。
命令速查
装好 TokenMeter 之后,90% 的时候只用三个命令。剩下 10% 是报告、清理、标签。
每日三件套
| 命令 | 做什么 |
|---|---|
tm setup | 一次性:把 hook 注入 ~/.claude/settings.json |
tm | 启 TUI(自动拉起 daemon) |
tm web | 启浏览器 Dashboard,默认 :8370 |
报告与清理
| 命令 | 做什么 |
|---|---|
tm status | 快速查看会话摘要 |
tm cost [today|week] | Token 用量与费用统计 |
tm report [session] | 单会话详细文本报告 |
tm report --weekly | 本周 Markdown 费用报告(按模型、按会话) |
tm report --monthly | 本月 Markdown 费用报告 |
tm tag <id> "备注" | 给会话打标签;省略 text 则清除 |
tm clean [days] | 清理 N 天前的历史(默认 7) |
守护进程与卸载
| 命令 | 做什么 |
|---|---|
tm daemon | 只启 daemon,不开 TUI |
tm uninstall | 移除 hooks、停 daemon |
rm -rf ~/.tokenmeter | 删除所有数据 |
tm version | 显示版本 |
~/.tokenmeter/data/tokenmeter.db — SQLite 数据库~/.tokenmeter/tokenmeter.sock — Unix socket~/.tokenmeter/daemon.pid — PID 锁文件
合上手册时带走的
TokenMeter 的形状是被几个不愿妥协的原则塑造的——这些原则决定了它是什么、以及它不是什么。
tm setup + tm 两条命令到位。纯 Go,没有外部 runtime、没有数据库服务、没有 Python 环境。Event。未来加 Cursor、Cline 也不用改 daemon 和 storage。source_id 唯一索引、INSERT OR IGNORE、内存去重 map 三层保护。rm -rf 一键清空。TokenMeter 是给 AI 编码 agent 装的本地仪表盘——一个 Go 写的 daemon 接住 Claude Code 和 Codex 的所有信号,统一成事件落进 SQLite,再用 TUI 和 Web 两套界面回放。零配置、幂等、本地、解耦。