把 Claude Code 的额度、上下文、花费和任务进度写成小文件,给桌宠Claude娘读取

<img src="buddy-art/idle.png" width="220" align="right">
由于我实在受不了了每次点击右下角的额度窗口查看额度和估算用量,所以制作了一个桌宠(感谢image2.5和opus5.5大人神力)。
这是什么:这是一个Windows 桌面宠物。一个捧书的小姑娘,脚下显示 Claude 的 5 小时与 7 天额度,会闲聊、会提醒额度告急,会在Claude Code 完成任务时为你欢呼。随附一个 Claude Code 插件,把额度、上下文、花费和任务进度实时同步给她(不这样的话她没法即时同步状态)。
声明:这是粉丝自制的非官方项目,与 Anthropic 没有任何关系,也没有得到其认可。Claude 是 Anthropic 的商标。
buddy-pet/:程序、插件、打包脚本buddy-art/:角色图和生图提示词Thanks to the LINUX DO community.
以 MIT 许可发布,见 LICENSE。
hooks/register.ts 150 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// 桌宠Claude娘读的目录是 <用户目录>/.claude/buddy-pet/live,每个会话一个 <会话 id>.json。
4// 位置固定,桌宠装在哪里、是否打包成 exe 都按这个路径找。文件里只有额度百分比、重置时间、
5// 上下文 token、花费和任务的起止时间,不写提示词、回答或项目路径。
6const HEARTBEAT_MS = 60_000
7
8type Limit = { kind: string; percentUsed: number; resetsAt?: string }
9type Measure = {
10 context: { tokens?: number; window: number; percent?: number }
11 rateLimits: readonly Limit[]
12 cost?: { usd: number }
13}
14type Turn = {
15 state: 'busy' | 'idle'
16 seq: number
17 startedAt?: number
18 endedAt?: number
19 reason?: string
20 durationMs?: number
21}
22
23// 模块重新加载时这些都从头开始,下一次 session.measure 会补齐。
24const live = {
25 folder: undefined as string | undefined,
26 startedAt: undefined as number | undefined,
27 context: undefined as Measure['context'] | undefined,
28 cost: undefined as number | undefined,
29 measuredAt: 0,
30 // 第一次响应之前(还有重新加载、换模型、恢复会话之后)引擎报不出额度,这时沿用上一次的读数。
31 limits: [] as Limit[],
32 limitsAt: 0,
33 turn: { state: 'idle', seq: 0 } as Turn,
34 isTicking: false,
35 isWarned: false,
36 queue: Promise.resolve(),
37}
38
39async function locate($: EngineInterface): Promise<string | undefined> {
40 if (live.folder === undefined) {
41 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
42 if (!home) return undefined
43 const sep = home.includes('\\') ? '\\' : '/'
44 live.folder = [home.replace(/[\\/]+$/, ''), '.claude', 'buddy-pet', 'live'].join(sep)
45 }
46
47 return live.folder
48}
49
50function take(m: Measure, now: number): void {
51 live.context = { tokens: m.context.tokens, window: m.context.window, percent: m.context.percent }
52 if (m.cost) live.cost = m.cost.usd
53 if (m.rateLimits.length) {
54 live.limits = m.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
55 live.limitsAt = now
56 }
57 live.measuredAt = now
58}
59
60async function write($: EngineInterface, isEnded: boolean): Promise<void> {
61 const dir = await locate($)
62 if (dir === undefined) return
63 // 会话 id 每次重新取:/clear 之后进程换一个 id 继续跑,自动写到新文件。
64 const session = await $.session.id()
65 const body = {
66 v: 1,
67 session,
68 updated: await $.clock.now(),
69 startedAt: live.startedAt,
70 limits: live.limits,
71 limitsAt: live.limitsAt,
72 context: live.context,
73 cost: live.cost,
74 measuredAt: live.measuredAt,
75 turn: live.turn,
76 ended: isEnded,
77 }
78 await $.fs.write(`${dir}${dir.includes('\\') ? '\\' : '/'}${session}.json`, JSON.stringify(body))
79}
80
81// 写入排队进行,每次写整份内容,后一次覆盖前一次;写不进去只提示一次。
82function save($: EngineInterface, isEnded = false): Promise<void> {
83 live.queue = live.queue
84 .then(() => write($, isEnded))
85 .catch((err: unknown) => {
86 if (live.isWarned) return
87 live.isWarned = true
88 $.ui.toast(`claude-buddy-bridge 写不进桌宠的数据文件:${err instanceof Error ? err.message : String(err)}`)
89 })
90
91 return live.queue
92}
93
94function beat($: EngineInterface): void {
95 if (live.isTicking) return
96 live.isTicking = true
97 // 心跳:桌宠据此判断会话还在;超过三分钟没更新的会话,她就不再当它在干活。
98 $.clock.every(HEARTBEAT_MS, () => {
99 void save($)
100 })
101}
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 const usage = await $.session.usage()
106 live.startedAt = usage.startedAt
107 take(usage, await $.clock.now())
108 void save($)
109 beat($)
110
111 return next(e)
112 })
113
114 on('session.measure', async ($, e, next) => {
115 take(e, await $.clock.now())
116 void save($)
117
118 return next(e)
119 })
120
121 on('turn.start', async ($, e, next) => {
122 live.turn = { state: 'busy', seq: live.turn.seq + 1, startedAt: await $.clock.now() }
123 void save($)
124
125 return next(e)
126 })
127
128 on('turn.complete', async ($, e, next) => {
129 // 子代理的回合只是主回合里的一步,不算任务结束。
130 if (e.agentId) return next(e)
131 live.turn = { ...live.turn, state: 'idle', endedAt: await $.clock.now(), reason: e.reason, durationMs: e.durationMs }
132 void save($)
133
134 return next(e)
135 })
136
137 on('session.end', async ($, e, next) => {
138 if (live.turn.state === 'busy') {
139 live.turn = { ...live.turn, state: 'idle', endedAt: await $.clock.now(), reason: 'ended' }
140 }
141 await save($, true)
142 // /clear 之后进程换一个会话 id 继续跑,新会话从零算起。
143 live.context = undefined
144 live.cost = undefined
145 live.startedAt = await $.clock.now()
146
147 return next(e)
148 })
149}
150