SLOPSHOPPER

pet-bridge

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

newguardcommandtoastpromptprocess
v0.1.0no licenseupdated 2026-10-15tombirdQAQ/Claude-Pet/mod/pet-bridge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pet-bridge
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pet ⎿ pet-bridge: ClaudePet 启动失败:请确认 ~/Applications/ClaudePet.app 存在。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ClaudePet

一只住在桌面上的像素小螃蟹,实时显示 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
  • 点击宠物:展开会话列表,可以查看每个会话的进度、最后一问一答,直接回复 (mod 每 2 秒拉取一次,在 Claude 空闲时以你的名义提交),或前往对应的 VS Code / 终端窗口
  • 拖动换位置;右键可以调大小、开关气泡和声音、设置登录时启动、隐藏宠物

/pet 命令

在任意 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 即可生效。

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