Forwards live session activity (thinking, tools, permissions, answers) to the ClaudePet desktop pet.

一只住在桌面上的像素小螃蟹,实时显示 Claude Code 在干什么。
App/:macOS 悬浮窗应用(Swift / SwiftUI,无第三方依赖)mod/pet-bridge/:Claude Code mod,把会话事件推给 App.claude-plugin/marketplace.json:本地 marketplace,插件直接从本文件夹读取需要 macOS 14+、Xcode 命令行工具和 Claude Code。
git clone https://github.com/tombirdQAQ/Claude-Pet.git && cd Claude-Pet
scripts/build-app.sh # 构建并安装 ~/Applications/ClaudePet.app
然后在 Claude Code 里安装 mod:
/plugin install pet-bridge --marketplace tombirdQAQ/Claude-Pet
Claude Code ──(pet-bridge mod)──▶ ~/Library/Application Support/ClaudePet/pet.sock ──▶ ClaudePet.app
mod 监听这些事件:流式思考和输出(turn.step)、工具调用(工具名、参数摘要、成败、耗时)、 授权请求、回合开始和结束、子代理、压缩上下文。事件先在 mod 内排队,每 150ms 通过 Unix socket 批量 POST 给 App,不会阻塞 Claude。App 没运行时,mod 会自动 open 它。
App 端:
+N在任意 Claude Code 会话里使用,Claude 正在干活时也能立即执行:
| 命令 | 作用 | ||||
|---|---|---|---|---|---|
/pet | 显示宠物并同步本会话;App 没运行时会启动它 | ||||
/pet hide | 隐藏宠物(/pet 唤回) | ||||
/pet list | 展开会话列表 | ||||
| `/pet size 小\ | 中\ | 大` | 调整大小,也可以写 `s\ | m\ | l` |
| `/pet bubble on\ | off` | 开关活动气泡 | |||
| `/pet sound on\ | off` | 开关声音提示 | |||
/pet reset | 回到屏幕右下角 | ||||
/pet status | 查看状态 | ||||
/pet quit | 退出 App;之后所有会话都不会再自动拉起它,直到输入 /pet |
scripts/build-app.sh # 构建、安装到 ~/Applications 并重启 App
"$(swift build -c release --package-path App --show-bin-path)/ClaudePet" snapshot /tmp/sheet.png # 所有状态的总览图
claude plugin test mod/pet-bridge # mod 测试
claude plugin validate . # 校验 marketplace 和 mod
App 图标由 App/Sources/ClaudePet/Icon.swift 绘制(ClaudePet icon <dir.iconset>), build-app.sh 每次构建都会重新生成并打包成 AppIcon.icns。
改了 mod/pet-bridge 之后,在 Claude Code 里执行 /reload-plugins 即可生效。
hooks/register.ts 347 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The ClaudePet app listens on a Unix socket; every request stays on this machine.
4const SOCKET = 'Library/Application Support/ClaudePet/pet.sock'
5// Holds "1" after `/pet quit`, so no session relaunches the app until `/pet`.
6const PAUSED = 'Library/Application Support/ClaudePet/paused'
7const APP = 'Applications/ClaudePet.app'
8const BASE = 'http://claudepet'
9
10const FLUSH_MS = 150
11const INBOX_MS = 2000
12const RELAUNCH_MS = 20_000
13const MAX_QUEUE = 300
14const LIVE_CHARS = 240
15
16const HELP = [
17 '/pet 用法:',
18 ' /pet 显示宠物并同步本会话(未运行时启动)',
19 ' /pet hide 隐藏宠物',
20 ' /pet list 展开会话列表',
21 ' /pet size 小|中|大 调整大小(也可用 s|m|l)',
22 ' /pet bubble on|off 开关活动气泡',
23 ' /pet sound on|off 开关声音提示',
24 ' /pet reset 回到屏幕右下角',
25 ' /pet status 查看状态',
26 ' /pet quit 退出 ClaudePet(输入 /pet 重新启动)',
27].join('\n')
28
29const ACTIONS = ['show', 'hide', 'list', 'size', 'bubble', 'sound', 'reset', 'status', 'quit']
30
31type PetEvent = { type: string; agent?: string; [field: string]: unknown }
32type Live = { kind: 'thinking' | 'text'; text: string; agent?: string; isDirty: boolean }
33
34// The module's own state: a reload starts it over, and session.start fills it again.
35const bridge = {
36 home: '',
37 session: '',
38 cwd: '',
39 host: undefined as string | undefined,
40 model: undefined as string | undefined,
41 isConnected: false,
42 needsHello: true,
43 isFlushing: false,
44 lastLaunch: 0,
45 queue: [] as PetEvent[],
46 live: new Map<string, Live>(),
47}
48
49function pushLive() {
50 for (const l of bridge.live.values()) {
51 if (!l.isDirty) continue
52 bridge.queue.push({ type: l.kind, text: l.text, agent: l.agent })
53 l.isDirty = false
54 }
55}
56
57function trimQueue() {
58 const { queue } = bridge
59 if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE)
60}
61
62function enqueue(ev: PetEvent) {
63 pushLive()
64 bridge.queue.push(ev)
65 trimQueue()
66}
67
68// Streamed text is coalesced: the app gets the latest tail once per flush, not every token.
69function stream(agent: string | undefined, kind: Live['kind'], piece: string) {
70 const key = agent ?? ''
71 let l = bridge.live.get(key)
72 if (l === undefined || l.kind !== kind) {
73 pushLive()
74 l = { kind, text: '', agent, isDirty: false }
75 bridge.live.set(key, l)
76 }
77 l.text = (l.text + piece).slice(-LIVE_CHARS)
78 l.isDirty = true
79}
80
81async function isPaused($: EngineInterface) {
82 try {
83 return (await $.fs.read(`${bridge.home}/${PAUSED}`)).trim() === '1'
84 } catch {
85 return false
86 }
87}
88
89async function setPaused($: EngineInterface, paused: boolean) {
90 try {
91 await $.fs.write(`${bridge.home}/${PAUSED}`, paused ? '1' : '')
92 } catch {
93 // No support folder yet: the app has never run, so nothing is paused.
94 }
95}
96
97async function launch($: EngineInterface) {
98 const now = Date.now()
99 if (bridge.home === '' || now - bridge.lastLaunch < RELAUNCH_MS) return
100 bridge.lastLaunch = now
101 if (await isPaused($)) return
102 try {
103 await $.process.run(['/usr/bin/open', '-g', '-a', `${bridge.home}/${APP}`])
104 } catch {
105 // Not installed: stay quiet, the session goes on without a pet.
106 }
107}
108
109async function request($: EngineInterface, method: string, path: string, body?: unknown) {
110 const res = await $.http.fetch(`${BASE}${path}`, {
111 method,
112 socketPath: `${bridge.home}/${SOCKET}`,
113 headers: { 'content-type': 'application/json' },
114 body: body === undefined ? undefined : JSON.stringify(body),
115 })
116 if (!res.ok) throw new Error(`ClaudePet answered ${res.status}`)
117 return res.text
118}
119
120// Asks the app to act on a `/pet` subcommand; undefined when it is not running.
121async function control($: EngineInterface, action: string, value: string) {
122 try {
123 const text = await request($, 'POST', '/control', { action, value })
124 return JSON.parse(text) as { ok?: boolean; message?: string }
125 } catch {
126 return undefined
127 }
128}
129
130async function runPet($: EngineInterface, args: string) {
131 const [word = 'show', ...rest] = args.trim().split(/\s+/).filter(Boolean)
132 const action = word.toLowerCase()
133 if (action === 'help' || action === '-h' || action === '--help') return HELP
134 if (!ACTIONS.includes(action)) return `未知子命令「${word}」。\n\n${HELP}`
135
136 if (action === 'show') {
137 await setPaused($, false)
138 let answer = await control($, action, '')
139 if (answer === undefined) {
140 bridge.lastLaunch = 0
141 await launch($)
142 for (let i = 0; i < 20 && answer === undefined; i += 1) {
143 await $.clock.sleep(250)
144 answer = await control($, action, '')
145 }
146 }
147 if (answer === undefined) return 'ClaudePet 启动失败:请确认 ~/Applications/ClaudePet.app 存在。'
148 bridge.needsHello = true
149 await flush($)
150 return answer.message ?? 'ClaudePet 已显示。'
151 }
152
153 if (action === 'quit') await setPaused($, true)
154 const answer = await control($, action, rest.join(' '))
155 if (answer === undefined) {
156 return action === 'quit' ? 'ClaudePet 没有在运行。' : 'ClaudePet 没有在运行,输入 /pet 启动。'
157 }
158 return answer.message ?? ''
159}
160
161async function flush($: EngineInterface) {
162 pushLive()
163 const { session, cwd, host, model } = bridge
164 if (bridge.isFlushing || session === '' || (bridge.queue.length === 0 && !bridge.needsHello)) return
165 bridge.isFlushing = true
166 const events = bridge.queue.splice(0, bridge.queue.length)
167 if (bridge.needsHello) events.unshift({ type: 'hello', model })
168 try {
169 await request($, 'POST', '/events', { session, cwd, host, events })
170 bridge.isConnected = true
171 bridge.needsHello = false
172 } catch {
173 bridge.isConnected = false
174 bridge.needsHello = true
175 // Keep what was not delivered (minus the hello) for when the app is back.
176 bridge.queue.unshift(...events.filter(ev => ev.type !== 'hello'))
177 trimQueue()
178 void launch($)
179 } finally {
180 bridge.isFlushing = false
181 }
182}
183
184async function pollInbox($: EngineInterface) {
185 if (bridge.session === '' || !bridge.isConnected) return
186 try {
187 const text = await request($, 'GET', `/inbox?session=${encodeURIComponent(bridge.session)}`)
188 const { messages } = JSON.parse(text) as { messages?: string[] }
189 for (const message of messages ?? []) {
190 await $.prompt.submit({ text: message, asUser: true })
191 $.ui.toast('ClaudePet: 已提交来自桌宠的回复')
192 }
193 } catch {
194 bridge.isConnected = false
195 bridge.needsHello = true
196 }
197}
198
199export const register: Register = on => {
200 on('session.start', async ($, e, next) => {
201 const result = await next(e)
202 bridge.home = (await $.env.get('HOME')) ?? ''
203 bridge.session = await $.session.id()
204 bridge.cwd = e.cwd
205 bridge.model = await $.session.model()
206 bridge.host = (await $.env.get('__CFBundleIdentifier')) ?? (await $.env.get('TERM_PROGRAM'))
207 bridge.needsHello = true
208 bridge.queue.length = 0
209 bridge.live.clear()
210
211 $.clock.every(FLUSH_MS, () => void flush($))
212 $.clock.every(INBOX_MS, () => void pollInbox($))
213 await $.command.register({
214 name: 'pet',
215 description: '控制 ClaudePet 桌宠:显示、隐藏、会话列表、大小、气泡、声音、退出',
216 argumentHint: '[show|hide|list|size|bubble|sound|reset|status|quit|help]',
217 immediate: true,
218 })
219 void flush($)
220
221 return result
222 })
223
224 on('command.run', { command: 'pet' }, async ($, e) => ({ text: await runPet($, e.args) }))
225
226 on('session.end', async ($, e, next) => {
227 enqueue({ type: 'end', reason: e.reason })
228 await flush($)
229 return next(e)
230 })
231
232 on('prompt.submit', ($, e, next) => {
233 if (e.isMeta !== true) {
234 const text = e.command === undefined ? e.text : `/${e.command.name} ${e.command.args ?? ''}`
235 enqueue({ type: 'prompt', text: text.slice(0, 500) })
236 }
237 return next(e)
238 })
239
240 on('turn.start', ($, e, next) => {
241 bridge.live.delete(e.agentId ?? '')
242 enqueue({
243 type: 'turn.start',
244 agent: e.agentId,
245 text: e.agentId === undefined ? undefined : e.text.slice(0, 200),
246 })
247 return next(e)
248 })
249
250 on('turn.step', async function* ($, e, next) {
251 for await (const chunk of next(e)) {
252 if (chunk.kind === 'thinking') stream(e.agentId, 'thinking', chunk.text)
253 else if (chunk.kind === 'text') stream(e.agentId, 'text', chunk.text)
254 else if (chunk.kind === 'tool') enqueue({ type: 'tool.prep', tool: chunk.name, agent: e.agentId })
255 yield chunk
256 }
257 })
258
259 on('tool.call', async ($, e, next) => {
260 const tool = String(e.tool)
261 const startedAt = Date.now()
262 enqueue({ type: 'tool.start', tool, id: e.tool_use_id, detail: describe(tool, e), agent: e.agentId })
263 const result = await next(e)
264 enqueue({
265 type: 'tool.end',
266 tool,
267 id: e.tool_use_id,
268 agent: e.agentId,
269 ok: result.deny === undefined && result.isError !== true,
270 denied: result.deny !== undefined,
271 ms: Date.now() - startedAt,
272 })
273 return result
274 }).catch(($, e, next) => next(e))
275
276 on('classic.PermissionRequest', ($, e, next) => {
277 enqueue({ type: 'permission', tool: e.tool_name, detail: describe(e.tool_name, e.tool_input) })
278 return next(e)
279 })
280
281 on('classic.PermissionDenied', ($, e, next) => {
282 enqueue({ type: 'denied' })
283 return next(e)
284 })
285
286 on('classic.Notification', ($, e, next) => {
287 enqueue({ type: 'notification', kind: e.notification_type, text: e.message.slice(0, 300) })
288 return next(e)
289 })
290
291 on('classic.PreCompact', ($, e, next) => {
292 enqueue({ type: 'compact' })
293 return next(e)
294 })
295
296 on('turn.complete', async ($, e, next) => {
297 const result = await next(e)
298 bridge.live.delete(e.agentId ?? '')
299 enqueue({ type: 'turn.end', agent: e.agentId, reason: e.reason, text: result.text.slice(0, 1500) })
300 return result
301 })
302}
303
304// A short, human label for what a tool call is about.
305function describe(tool: string, input: unknown): string {
306 const a = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
307 const str = (key: string) => (typeof a[key] === 'string' ? (a[key] as string) : '')
308 const base = (path: string) => path.split('/').filter(Boolean).pop() ?? path
309 const line = (text: string) => (text.split('\n')[0] ?? '').trim().slice(0, 120)
310
311 switch (tool) {
312 case 'Bash':
313 case 'PowerShell':
314 return line(str('command'))
315 case 'Read':
316 case 'Edit':
317 case 'Write':
318 case 'MultiEdit':
319 return base(str('file_path'))
320 case 'NotebookEdit':
321 return base(str('notebook_path'))
322 case 'Glob':
323 case 'Grep':
324 return line(str('pattern'))
325 case 'WebFetch':
326 return str('url').replace(/^https?:\/\//, '').split('/')[0] ?? ''
327 case 'WebSearch':
328 return line(str('query'))
329 case 'Agent':
330 case 'Task':
331 return line(str('description') || str('subagent_type'))
332 case 'Skill':
333 return str('skill')
334 case 'TodoWrite':
335 return Array.isArray(a.todos) ? `${a.todos.length} 项` : ''
336 case 'AskUserQuestion':
337 return Array.isArray(a.questions) && a.questions.length > 0
338 ? line(String((a.questions[0] as Record<string, unknown>).question ?? ''))
339 : ''
340 }
341 if (tool.startsWith('mcp__')) {
342 const [, server, name] = tool.split('__')
343 return `${server} · ${name}`
344 }
345 return line(str('description') || str('file_path') || str('path') || str('pattern') || str('url'))
346}
347