SLOPSHOPPER

done-ping

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

newpromptprocess
v0.2.0no licenseupdated 2026-10-08YuehengHan/my-claude-mods/plugins/done-ping
A shopper browsing a rack in a slop shop
README

my-claude-mods

Claude Code mods (function-hook plugins) for working across many repos and many sessions at once — plus a pet.

ModWhat it does
where-am-iA band above the prompt: 📁 repo 🌿 branch ↑2 ± 3 changed 🎯 task. /task <text> names what this session is doing.
done-pingA 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-guardBlocks 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-petA 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-moodClaude's mood in the status line: 🤔 reading, ✍️ writing, 🧪 testing, 😤 after a few errors, 😌 when done — with 📖 ✏️ ⚡ 💥 counters.
control-towerA 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.
tldrAfter every long answer, a dim 💡 TL;DR line (Haiku) in the transcript, so a session you switch back to reads at a glance.
sdk-syncRelease 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.
fortuneProgrammer jokes after the working spinner (Sauteing… 🎲 删库跑路前,记得先 git push。); the day's first prompt shows 今日运势 (/fortune). Fridays never deploy.

Install

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.

Developing

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