SLOPSHOPPER

claude-buddy-bridge

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

newtoasttimer
A shopper browsing a rack in a slop shop
README

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/README.md
  • buddy-pet/:程序、插件、打包脚本
  • buddy-art/:角色图和生图提示词

Acknowledgements

Thanks to the LINUX DO community.

License

以 MIT 许可发布,见 LICENSE。

Source 1 files
hooks/register.ts 150 lines
1import 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