SLOPSHOPPER

ping

Plays a chime and shows a toast when a long turn finishes (or Claude is waiting on your approval), so you can walk away.

newguardcommandtoastaudio
★ 1v0.1.0no licenseupdated 2026-10-08TroyJLorents-GH/mod-squad/mods/ping
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ping
› fix the failing auth test and add an audit log call ╭───────────────╮ │ ping │ ⏺ Read(src/auth.ts) │ ✔ Done in 42s │ ⎿ 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 › /ping ⎿ ping: Ping: on · turns ≥ 30s · sound on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ping

Plays a chime and shows a toast when a long Claude Code turn finishes, so you can walk away.

/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install ping@mod-squad
  • Done: a turn that ran at least minSeconds plays a two-note chime and toasts ✔ Done in 2m 14s.
  • Error / refusal: a low two-note chime and ✖ Turn ended: error after 1m 3s.
  • Aborted (Esc): nothing; you're already there. Subagent turns never ping.
  • Waiting on you: when a tool call needs your approval after the turn has run minSeconds, a soft single tone and ⏳ Waiting for your approval (Bash) after 45s. (In auto mode the classifier may answer the ask before you do.)
  • Commands: /ping shows settings, /ping on, /ping off (remembered across sessions), /ping test plays the done chime.
  • Settings: minSeconds (default 30), sound (default on; off = toast only).

Sound depends on the surface: it plays with afplay on macOS; Linux/Windows terminals and remote/mobile sessions have no player, so you get the toast only.

Chimes live in sounds/ (44.1 kHz mono 16-bit WAV); regenerate them with python3 scripts/make-sounds.py.

Test: claude plugin test mods/ping. Validate: claude plugin validate mods/ping.

Source 1 files
hooks/register.ts 113 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type Options = { minSeconds?: number; sound?: boolean }
4
5export const SOUNDS = {
6  done: 'sounds/done.wav',
7  error: 'sounds/error.wav',
8  attention: 'sounds/attention.wav',
9} as const
10
11/** 45s, 2m 14s, 1h 3m. */
12export function formatDuration(ms: number): string {
13  const s = Math.max(0, Math.round(ms / 1000))
14  if (s < 60) return `${s}s`
15  const m = Math.floor(s / 60)
16  if (m < 60) return `${m}m ${s % 60}s`
17  return `${Math.floor(m / 60)}h ${m % 60}m`
18}
19
20async function isOn($: EngineInterface): Promise<boolean> {
21  try {
22    return (await $.store.get('enabled')) !== false
23  } catch {
24    return true
25  }
26}
27
28/** Toast, then the chime when sound is on; a surface with no audio (or any failure) leaves the toast alone. */
29async function ping($: EngineInterface, text: string, asset: string | undefined, sound: boolean) {
30  try {
31    $.ui.toast(text)
32  } catch {}
33  if (!sound || asset === undefined) return
34  try {
35    await $.audio.play({ asset })
36  } catch {}
37}
38
39async function now($: EngineInterface): Promise<number | undefined> {
40  try {
41    return await $.clock.now()
42  } catch {
43    return undefined
44  }
45}
46
47export const register: Register = (on, options) => {
48  const opts = (options ?? {}) as Options
49  const minMs = Math.max(0, Number(opts.minSeconds ?? 30)) * 1000
50  const sound = opts.sound !== false
51
52  // When the current main-loop turn started (module state; a reload resetting it is harmless).
53  let turnStartedAt: number | undefined
54  const asked = new Set<string>()
55
56  on('session.start', async ($, e, next) => {
57    await $.command.register({
58      name: 'ping',
59      description: 'Ping when a long turn finishes: on | off | test, or bare to show settings',
60      argumentHint: '[on | off | test]',
61      immediate: true,
62    })
63    return next(e)
64  })
65
66  on('turn.start', async ($, e, next) => {
67    turnStartedAt = await now($)
68    asked.clear()
69    return next(e)
70  })
71
72  on('turn.complete', async ($, e, next) => {
73    const result = await next(e)
74    if (e.agentId !== undefined || e.durationMs < minMs || e.reason === 'aborted') return result
75    if (!(await isOn($))) return result
76    const took = formatDuration(e.durationMs)
77    if (e.reason === 'answer') await ping($, `✔ Done in ${took}`, SOUNDS.done, sound)
78    else await ping($, `✖ Turn ended: ${e.reason} after ${took}`, SOUNDS.error, sound)
79    return result
80  })
81
82  // A real call whose verdict is `ask` goes to the person (or, in auto mode, the classifier).
83  on('tool.check', async ($, e, next) => {
84    const verdict = await next(e)
85    try {
86      if (verdict.decision !== 'ask' || e.tool_use_id === undefined || asked.has(e.tool_use_id)) return verdict
87      asked.add(e.tool_use_id)
88      const t = await now($)
89      if (turnStartedAt === undefined || t === undefined || t - turnStartedAt < minMs) return verdict
90      if (!(await isOn($))) return verdict
91      await ping($, `⏳ Waiting for your approval (${e.tool}) after ${formatDuration(t - turnStartedAt)}`, SOUNDS.attention, sound)
92    } catch {}
93    return verdict
94  }).catch(($, e, next) => next(e)) // we never gate: on any failure, the verdict beneath stands
95
96  on('command.run', { command: 'ping' }, async ($, e) => {
97    const verb = e.args.trim().toLowerCase()
98    if (verb === 'on' || verb === 'off') {
99      await $.store.set('enabled', verb === 'on')
100      return { text: `Ping: ${verb}.` }
101    }
102    if (verb === 'test') {
103      await ping($, '✔ Ping test', SOUNDS.done, sound)
104      return { text: sound ? 'Ping: played done.wav (if this surface can play audio).' : 'Ping: sound is off in settings; toast only.' }
105    }
106    if (verb !== '') return { text: 'Usage: /ping [on | off | test]' }
107    const enabled = await isOn($)
108    return {
109      text: `Ping: ${enabled ? 'on' : 'off'} · turns ≥ ${minMs / 1000}s · sound ${sound ? 'on' : 'off (toast only)'}`,
110    }
111  })
112}
113