When a long turn finishes or Claude needs you: a macOS notification with a distinct sound for done, failed and needs-you

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 92 lines1import type { EngineInterface, Register } from 'claude-code'
2
3/** Turns shorter than this finish while you are still watching: no ping. */
4const MIN_TURN_MS = 30_000
5
6/**
7 * One macOS system sound per kind of ping, so you can tell them apart without
8 * looking. Any name in /System/Library/Sounds works: Basso Blow Bottle Frog
9 * Funk Glass Hero Morse Ping Pop Purr Sosumi Submarine Tink.
10 */
11export const SOUNDS = {
12 done: 'Glass',
13 failed: 'Basso',
14 needsYou: 'Submarine',
15} as const
16
17/** afplay volume: 1 is full, 0.05 a whisper. */
18export const VOLUME = 0.05
19
20const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
21
22export function formatDuration(ms: number): string {
23 const s = Math.round(ms / 1000)
24 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
25}
26
27export function snippet(text: string, max = 60): string {
28 const one = text.replace(/\s+/g, ' ').trim()
29 return one.length > max ? `${one.slice(0, max - 1)}…` : one
30}
31
32let repoName = ''
33let lastPrompt = ''
34
35async function repo($: EngineInterface): Promise<string> {
36 if (repoName) return repoName
37 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
38 repoName = basename(top.exitCode === 0 ? top.stdout.trim() : await $.session.cwd())
39 return repoName
40}
41
42async function ping($: EngineInterface, title: string, body: string, sound: string) {
43 // afplay plays even when Focus mode mutes notification sounds.
44 void $.process.run(['afplay', '-v', String(VOLUME), `/System/Library/Sounds/${sound}.aiff`], { timeoutMs: 10_000 }).catch(() => undefined)
45 // argv keeps quotes in the prompt text from breaking the AppleScript.
46 await $.process
47 .run(
48 [
49 'osascript',
50 '-e', 'on run argv',
51 '-e', 'display notification (item 2 of argv) with title (item 1 of argv)',
52 '-e', 'end run',
53 title,
54 body,
55 ],
56 { timeoutMs: 5000 },
57 )
58 .catch(() => undefined)
59}
60
61export const register: Register = on => {
62 on('prompt.submit', ($, e, next) => {
63 lastPrompt = e.text
64 return next(e)
65 })
66
67 on('turn.complete', async ($, e, next) => {
68 const done = await next(e)
69 if (e.agentId !== undefined || e.isAborted || e.durationMs < MIN_TURN_MS) return done
70
71 const name = await repo($)
72 const ok = e.reason === 'answer'
73 void ping(
74 $,
75 `${ok ? '✅' : '❌'} ${name} · ${formatDuration(e.durationMs)}`,
76 snippet(lastPrompt) || (ok ? 'Done' : 'Ended with an error'),
77 ok ? SOUNDS.done : SOUNDS.failed,
78 )
79 return done
80 })
81
82 // Claude is blocked on you: a permission dialog or a question.
83 on('classic.Notification', async ($, e, next) => {
84 const ran = await next(e)
85 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
86 const name = await repo($)
87 void ping($, `✋ ${name} needs you`, snippet(e.message), SOUNDS.needsYou)
88 }
89 return ran
90 })
91}
92