A pet in the status line that levels up with your work, gets dizzy on errors, sleeps when you're away, and unlocks achievements

Claude Code mods (function-hook plugins) for working across many repos and many sessions at once — plus a pet.
| Mod | What it does |
|---|---|
| where-am-i | A band above the prompt: 📁 repo 🌿 branch ↑2 ± 3 changed 🎯 task. /task <text> names what this session is doing. |
| done-ping | A macOS notification (repo, duration, your prompt) with a sound per kind: 🔔 Glass when a turn over 30 s is done, Basso when it failed, Submarine when Claude is waiting on you. Change them in SOUNDS. |
| repo-guard | Blocks push --force, pushes to main/master, reset --hard, clean -f, checkout ., branch -D, stash clear, rm -rf ~. Asks before Claude first edits a repo other than the session's own. |
| claude-pet | A pet that earns xp from your work, grows through five stages and unlocks achievements. Pick a species (/pet species: chick, cat, dog, dragon, dino, ocean, bug, plant, robot, moon) or your own emojis (/pet emoji 🦊, /pet emoji 🥚 🐣 🦊 🐺 🐉). Lives in the status line (🦮 xixi (・_・)📖 Lv7 ▰▱▱▱▱▱) with 18 moods that follow Claude: 💭 thinking, 📖 reading, ✍️ writing, ⚡ running, 🧪 testing, 🌐 browsing, 📣 delegating, ✋ waiting for you, 🔥 on a 25-call streak, 🌙 past 1am, 🎉 tests pass, 📦 commit/push, (×_×) error, (╬ಠ益ಠ) three in a row, bored after 5 idle minutes, asleep after 15. /pet for stats, /pet name <name>. |
| claude-mood | Claude's mood in the status line: 🤔 reading, ✍️ writing, 🧪 testing, 😤 after a few errors, 😌 when done — with 📖 ✏️ ⚡ 💥 counters. |
| control-tower | A row under where-am-i's, shown only while another session waits for you (a permission or a question): ✋ backend-ng 在等你 2m [tower]. /tower (or the button) opens a pane listing every Claude session on this machine, named repo · branch for worktrees. |
| tldr | After every long answer, a dim 💡 TL;DR line (Haiku) in the transcript, so a session you switch back to reads at a glance. |
| sdk-sync | Release audit for the SpatialReal SDKs. Never acts while you work: when a new v* tag appears it reminds you once; /release-check <repo> [tag] gathers the facts (public API files changed, CHANGELOG entry, commits in each sibling SDK / docs / examples since the release, version pins) and asks Claude for a read-only audit table. /release-check status lists unchecked releases; --facts skips the audit. Graph: plugins/sdk-sync/hooks/sdk-map.ts. |
| fortune | Programmer jokes after the working spinner (Sauteing… 🎲 删库跑路前,记得先 git push。); the day's first prompt shows 今日运势 (/fortune). Fridays never deploy. |
At a Claude Code terminal prompt:
/plugin marketplace add YuehengHan/my-claude-mods
/plugin install where-am-i@my-claude-mods
/plugin install done-ping@my-claude-mods
/plugin install repo-guard@my-claude-mods
/plugin install claude-pet@my-claude-mods
/plugin install claude-mood@my-claude-mods
Pick the user scope so they load in every session. Install only the ones you want.
Add a local clone as the marketplace instead, and Claude Code reads the plugins straight from the folder:
claude plugin marketplace add ~/Desktop/my-claude-mods
claude plugin install where-am-i@my-claude-mods --scope user
Claude Code runs an installed copy, so after editing a mod bump its version in plugin.json, then:
claude plugin marketplace update my-claude-mods
claude plugin update <mod>@my-claude-mods
and run /reload-plugins (or restart) in each open session.
Check and test a mod:
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>hooks/register.ts 335 lines1import type { EngineInterface, Register } from 'claude-code'
2
3export type Pet = {
4 name: string
5 xp: number
6 achievements: string[]
7 /** A key of SPECIES; absent means chick. */
8 species?: string
9 /** Your own emojis: one for every stage, or one per stage in order. */
10 custom?: string[]
11}
12export type Mood =
13 | 'idle'
14 | 'thinking'
15 | 'reading'
16 | 'writing'
17 | 'running'
18 | 'testing'
19 | 'browsing'
20 | 'delegating'
21 | 'waiting'
22 | 'fired'
23 | 'yawning'
24 | 'happy'
25 | 'party'
26 | 'proud'
27 | 'dizzy'
28 | 'angry'
29 | 'bored'
30 | 'asleep'
31
32const KEY = 'pet'
33const BORED_AFTER_MS = 5 * 60_000
34const SLEEP_AFTER_MS = 15 * 60_000
35
36export const FACES: Record<Mood, string> = {
37 idle: '(・ω・)',
38 thinking: '( ̄~ ̄)💭',
39 reading: '(・_・)📖',
40 writing: '(`・ω・´)✍️',
41 running: '(•̀ᴗ•́)و⚡',
42 testing: '(°□°;)🧪',
43 browsing: '(⊙_⊙)🌐',
44 delegating: '٩(◕‿◕)۶📣',
45 waiting: '(・・?)✋',
46 fired: '(ง •̀_•́)ง🔥',
47 yawning: '(-o-)🌙',
48 happy: '(^ω^)',
49 party: '\(^o^)/🎉',
50 proud: '(๑•̀ㅂ•́)و📦',
51 dizzy: '(×_×)',
52 angry: '(╬ಠ益ಠ)',
53 bored: '( ̄ヘ ̄)',
54 asleep: '(-_-) zZ',
55}
56
57const READ_TOOLS: readonly string[] = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']
58const WRITE_TOOLS: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
59
60/** What the pet does while a tool runs. */
61export function moodForTool(tool: string, command = ''): Mood {
62 if (READ_TOOLS.includes(tool)) return 'reading'
63 if (WRITE_TOOLS.includes(tool)) return 'writing'
64 if (tool === 'Agent' || tool === 'Task') return 'delegating'
65 if (tool === 'WebFetch' || tool === 'WebSearch' || /browser|chrome/i.test(tool)) return 'browsing'
66 if (tool === 'Bash') return isTestCommand(command) ? 'testing' : 'running'
67 return 'running'
68}
69
70export function isNight(hour: number): boolean {
71 return hour >= 1 && hour < 5
72}
73
74export const ACHIEVEMENTS: Record<string, string> = {
75 hello: '👋 Hello world: first tool call together',
76 streak10: '🔥 On a roll: 10 tool calls in a row without an error',
77 streak50: '🌋 Unstoppable: 50 in a row without an error',
78 green: '✅ Green: a test run passed',
79 shipper: '📦 Shipper: made a git commit',
80 owl: '🦉 Night owl: working between 1am and 5am',
81 marathon: '🏃 Marathon: one turn ran over 10 minutes',
82 phoenix: '🐦🔥 Phoenix: tests passed right after they failed',
83 lv5: '⭐ Level 5',
84 lv10: '🌟 Level 10',
85}
86
87/** Level n needs 25·n·(n+1) xp in total: 50, 150, 300, 500, ... */
88export function levelOf(xp: number): { level: number; into: number; span: number } {
89 let level = 1
90 while (xp >= 25 * level * (level + 1)) level += 1
91 const floor = 25 * (level - 1) * level
92 return { level, into: xp - floor, span: 25 * level * (level + 1) - floor }
93}
94
95/** Five stages each, reached at levels 1, 2, 4, 7 and 10. */
96export const SPECIES: Record<string, { label: string; stages: readonly string[] }> = {
97 chick: { label: '小鸡', stages: ['🥚', '🐣', '🐥', '🐔', '🦅'] },
98 cat: { label: '猫', stages: ['🥚', '🐱', '😺', '🐈', '🦁'] },
99 dog: { label: '狗', stages: ['🥚', '🐶', '🐕', '🦮', '🐺'] },
100 dragon: { label: '龙', stages: ['🥚', '🦎', '🐊', '🐉', '🐲'] },
101 dino: { label: '恐龙', stages: ['🥚', '🦎', '🦕', '🦖', '☄️'] },
102 ocean: { label: '海洋', stages: ['🥚', '🐟', '🐠', '🐬', '🐳'] },
103 bug: { label: '虫虫', stages: ['🥚', '🐛', '🐌', '🐞', '🦋'] },
104 plant: { label: '植物', stages: ['🌰', '🌱', '🌿', '🪴', '🌳'] },
105 robot: { label: '机器人', stages: ['🔩', '⚙️', '🤖', '🦾', '🛸'] },
106 moon: { label: '月亮', stages: ['🌑', '🌒', '🌓', '🌔', '🌕'] },
107}
108
109export function stagesOf(pet: Pick<Pet, 'species' | 'custom'>): readonly string[] {
110 const custom = pet.custom ?? []
111 if (custom.length > 0) {
112 // Spread 1–5 emojis over the five stages: one emoji is the whole life.
113 return [0, 1, 2, 3, 4].map(i => custom[Math.floor((i * custom.length) / 5)] as string)
114 }
115 return (SPECIES[pet.species ?? 'chick'] ?? SPECIES.chick)!.stages
116}
117
118export function stageOf(level: number, stages: readonly string[] = SPECIES.chick!.stages): string {
119 const i = level >= 10 ? 4 : level >= 7 ? 3 : level >= 4 ? 2 : level >= 2 ? 1 : 0
120 return stages[i] ?? stages[stages.length - 1] ?? '🥚'
121}
122
123/** `🦊🐉` or `🦊 🐉` → ['🦊', '🐉'], whole emojis (ZWJ sequences, flags) kept together. */
124export function splitEmojis(text: string): string[] {
125 const words = text.trim().split(/\s+/).filter(Boolean)
126 const Seg = (Intl as unknown as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment: (t: string) => Iterable<{ segment: string }> } }).Segmenter
127 if (words.length !== 1 || Seg === undefined) return words.slice(0, 5)
128 return [...new Seg(undefined, { granularity: 'grapheme' }).segment(words[0] as string)].map(g => g.segment).slice(0, 5)
129}
130
131export function bar(into: number, span: number, width = 6): string {
132 const full = Math.min(width, Math.floor((into / span) * width))
133 return '▰'.repeat(full) + '▱'.repeat(width - full)
134}
135
136function isTestCommand(c: string): boolean {
137 return /\b(test|tests|pytest|jest|vitest|mocha|gradle\w*\s+\S*test|go\s+test|cargo\s+test|xcodebuild\s+test)\b/.test(c)
138}
139
140let pet: Pet = { name: 'Mochi', xp: 0, achievements: [] }
141let mood: Mood = 'idle'
142let streak = 0
143let lastActive = 0
144let testsFailedLast = false
145let errorRun = 0
146
147async function load($: EngineInterface): Promise<Pet> {
148 const stored = (await $.store.get(KEY)) as Partial<Pet> | undefined
149 return {
150 name: stored?.name ?? 'Mochi',
151 xp: stored?.xp ?? 0,
152 achievements: stored?.achievements ?? [],
153 species: stored?.species,
154 custom: stored?.custom,
155 }
156}
157
158/** The pet lives in the status line; every change of mood or xp redraws it. */
159function draw($: EngineInterface) {
160 const { level, into, span } = levelOf(pet.xp)
161 $.ui.status(`${stageOf(level, stagesOf(pet))} ${pet.name} ${FACES[mood]} Lv${level} ${bar(into, span)}${streak >= 5 ? ` 🔥${streak}` : ''}`)
162}
163
164// Re-read before writing so several sessions feeding the same pet don't lose xp.
165async function gain($: EngineInterface, xp: number, unlock: string[] = []) {
166 const before = levelOf(pet.xp).level
167 const fresh = await load($)
168 const newOnes = unlock.filter(a => !fresh.achievements.includes(a))
169 pet = { ...fresh, xp: fresh.xp + xp, achievements: [...fresh.achievements, ...newOnes] }
170 const after = levelOf(pet.xp).level
171 if (after >= 5 && !pet.achievements.includes('lv5')) newOnes.push('lv5'), pet.achievements.push('lv5')
172 if (after >= 10 && !pet.achievements.includes('lv10')) newOnes.push('lv10'), pet.achievements.push('lv10')
173 await $.store.set(KEY, pet)
174
175 for (const a of newOnes) $.ui.toast(`🏆 ${ACHIEVEMENTS[a]}`, { timeoutMs: 6000 })
176 if (after > before) {
177 $.ui.toast(`🎉 ${pet.name} reached level ${after}! ${stageOf(after, stagesOf(pet))}`, { timeoutMs: 6000 })
178 void $.process.run(['afplay', '-v', '0.05', '/System/Library/Sounds/Hero.aiff'], { timeoutMs: 10_000 }).catch(() => undefined)
179 mood = 'party'
180 }
181 draw($)
182}
183
184export const register: Register = on => {
185 on('session.start', async ($, e, next) => {
186 pet = await load($)
187 lastActive = await $.clock.now()
188 draw($)
189 await $.command.register({
190 name: 'pet',
191 description: 'Your pet: stats and achievements; rename it, pick a species or your own emojis',
192 argumentHint: '[name <name> | species [<kind>] | emoji <emoji…> | emoji reset]',
193 immediate: true,
194 })
195 $.clock.every(60_000, () => {
196 void $.clock.now().then(now => {
197 const quiet = now - lastActive
198 const resting: Mood | null = quiet > SLEEP_AFTER_MS ? 'asleep' : quiet > BORED_AFTER_MS ? 'bored' : null
199 if (resting !== null && resting !== mood) {
200 mood = resting
201 draw($)
202 }
203 })
204 })
205 return next(e)
206 })
207
208 on('prompt.submit', async ($, e, next) => {
209 const now = await $.clock.now()
210 lastActive = now
211 const night = isNight(new Date(now).getHours())
212 mood = night ? 'yawning' : 'thinking'
213 draw($)
214 if (night) void gain($, 0, ['owl'])
215 return next(e)
216 })
217
218 on('tool.call', async ($, e, next) => {
219 const command = e.tool === 'Bash' ? e.command : ''
220 if (e.agentId === undefined) {
221 mood = moodForTool(String(e.tool), command)
222 draw($)
223 }
224 const ran = await next(e)
225 if (e.agentId !== undefined || ran.deny !== undefined) return ran
226 lastActive = await $.clock.now()
227
228 if (ran.isError) {
229 streak = 0
230 errorRun += 1
231 if (isTestCommand(command)) testsFailedLast = true
232 mood = errorRun >= 3 ? 'angry' : 'dizzy'
233 draw($)
234 return ran
235 }
236 errorRun = 0
237
238 streak += 1
239 const unlock = ['hello']
240 let xp = 1
241 if (streak === 10) unlock.push('streak10')
242 if (streak === 50) unlock.push('streak50')
243 if (e.tool === 'Bash' && isTestCommand(e.command)) {
244 xp += 5
245 unlock.push('green')
246 if (testsFailedLast) unlock.push('phoenix')
247 testsFailedLast = false
248 mood = 'party'
249 } else if (/\bgit\s+(commit|push)\b/.test(command)) {
250 xp += 5
251 if (/\bgit\s+commit\b/.test(command)) unlock.push('shipper')
252 mood = 'proud'
253 } else {
254 // Between tools Claude is thinking again; a long clean run fires the pet up.
255 mood = streak >= 25 ? 'fired' : 'thinking'
256 }
257 void gain($, xp, unlock)
258 draw($)
259 return ran
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 const done = await next(e)
264 if (e.agentId !== undefined || e.isAborted) return done
265 void gain($, 3, e.durationMs > 10 * 60_000 ? ['marathon'] : [])
266 mood = e.reason !== 'answer' ? 'dizzy' : errorRun > 0 ? 'idle' : mood === 'party' || mood === 'proud' ? mood : 'happy'
267 draw($)
268 return done
269 })
270
271 on('classic.Notification', async ($, e, next) => {
272 const ran = await next(e)
273 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
274 mood = 'waiting'
275 draw($)
276 }
277 return ran
278 })
279
280 on('command.run', { command: 'pet' }, async ($, e) => {
281 const args = e.args.trim()
282 pet = await load($)
283 const save = async (next: Pet, text: string) => {
284 pet = next
285 await $.store.set(KEY, pet)
286 draw($)
287 return { text }
288 }
289
290 const named = /^(?:re)?name\s+(.+)$/.exec(args)
291 if (named) return save({ ...pet, name: (named[1] ?? '').trim().slice(0, 20) || pet.name }, `Your pet is now called ${(named[1] ?? '').trim().slice(0, 20) || pet.name} ${FACES.happy}`)
292
293 const kind = /^species(?:\s+(\S+))?$/.exec(args)
294 if (kind) {
295 const want = kind[1]
296 if (want === undefined || SPECIES[want] === undefined) {
297 const current = pet.custom?.length ? '' : pet.species ?? 'chick'
298 const list = Object.entries(SPECIES).map(
299 ([key, s]) => ` ${key === current ? '▸' : ' '} ${key.padEnd(8)} ${s.stages.join(' → ')} ${s.label}`,
300 )
301 return { text: [`${want ? `No species "${want}". ` : ''}Pick one with /pet species <kind>:`, ...list, '', 'Or your own: /pet emoji 🦊 · /pet emoji 🥚 🐣 🦊 🐺 🐉'].join('\n') }
302 }
303 const { level } = levelOf(pet.xp)
304 const next = { ...pet, species: want, custom: undefined }
305 return save(next, `${pet.name} is now a ${SPECIES[want]!.label}: ${stageOf(level, stagesOf(next))} (${SPECIES[want]!.stages.join(' → ')})`)
306 }
307
308 const emoji = /^emoji\s+(.+)$/.exec(args)
309 if (emoji) {
310 if (emoji[1]?.trim() === 'reset') return save({ ...pet, custom: undefined }, `${pet.name} is back to its species.`)
311 const custom = splitEmojis(emoji[1] ?? '')
312 if (custom.length === 0) return { text: 'Usage: /pet emoji 🦊 or /pet emoji 🥚 🐣 🦊 🐺 🐉' }
313 const next = { ...pet, custom }
314 return save(next, `${pet.name}'s stages: ${stagesOf(next).join(' → ')}`)
315 }
316
317 const { level, into, span } = levelOf(pet.xp)
318 const stages = stagesOf(pet)
319 const got = Object.keys(ACHIEVEMENTS)
320 .map(k => (pet.achievements.includes(k) ? ` ${ACHIEVEMENTS[k]}` : ' 🔒 ???'))
321 .join('\n')
322 return {
323 text: [
324 `${stageOf(level, stages)} ${pet.name} ${FACES[mood]}`,
325 `Level ${level} · ${pet.xp} xp (${into}/${span} to the next) · streak ${streak}`,
326 `Growth: ${stages.map((s, i) => (s === stageOf(level, stages) && [1, 2, 4, 7, 10][i]! <= level ? `[${s}]` : s)).join(' → ')}`,
327 `Achievements ${pet.achievements.length}/${Object.keys(ACHIEVEMENTS).length}:`,
328 got,
329 '',
330 '/pet name <name> · /pet species · /pet emoji <emoji…>',
331 ].join('\n'),
332 }
333 })
334}
335