SLOPSHOPPER

sounds

Toasts, configurable sounds and system notifications for Claude Code: usage limits, context, prompt-cache expiry, every way a turn ends, questions and…

guardtoasttimeraudio
v0.6.1MITupdated 2026-10-17giocaizzi/harness/plugins/sounds
A shopper browsing a rack in a slop shop
README

sounds plugin

Toasts and configurable sounds for Claude Code (a mod), and the turn and context alerts with the default clips for pi (hooks/pi.mjs), Codex (a Stop hook, hooks/codex.mjs) and opencode (hooks/opencode.mjs). No skills or agents. Architecture and runbook: docs/sounds.md.

Alerts and sounds

SettingPlays onDefault
soundLimit5h, soundLimit7d, soundLimitSpendThe window crossing 80%, 90%, 100% (with a toast)warn
soundContextContext crossing 80%, 90% (with a toast)alert
soundCacheExpiring, soundCacheExpiredA minute before the prompt cache expires, and when it has (with a toast)ping, warn
soundDoneA turn answered, at least doneAfterSeconds long (default 0)done
soundAborted, soundRefused, soundErrorA turn interrupted (also a message sent mid-turn), refused, or ended by an API erroroff, error, error
soundSubagentA subagent's turn endedtick
soundNeedsInputA permission prompt or a question waiting on you (also Codex and opencode)chime
notifySystem notification: a turn of at least notifyAfterSeconds (default 60) finishing or ending badly, Claude asking a question, the PR checks finishing (with hud)on

Each sound is a picker in /config over off, chime, ping, alert, warn, error, done, tick; soundGain sets the volume. Sounds play on macOS only.

Install

/plugin install sounds@giocaizzi-harness     # Claude Code
codex plugin add sounds@giocaizzi-harness    # Codex; trust the hook in /hooks
make install HARNESS=opencode                # opencode, from a checkout
Source 3 files
hooks/mod.mjs 113 lines
1// Claude Code mod (hooks.json `modules`; Copilot ignores the key): a toast and a sound for each limit, context and
2// prompt-cache alert, and a sound for each way a turn ends. Every sound is a `userConfig` picker in /config.
3import { atom, read, update } from 'claude-code'
4import { TTL_MS, alerts, ciNotice, clock, turnNotice, turnSound } from './alerts.mjs'
5
6// The last main-loop turn: when it ended, and the alert levels raised so far.
7const last = atom({ plugin: 'sounds', key: 'last' }, null)
8
9// A sound by its setting's value (`off` plays nothing); macOS only, elsewhere nothing plays.
10async function clip($, name, gain = 1) {
11  if (name && name !== 'off') await $.audio.play({ asset: `sounds/${name}.wav` }, { gain }).catch(() => {})
12}
13
14// A system notification through the person's own channel, for when the terminal is not in front; never fails a hook.
15async function notify($, text) {
16  try {
17    await $.ui.notify(text, { title: 'Claude Code' })
18  } catch {
19    // no channel, or a Notification hook refused it
20  }
21}
22
23// The context % and the rate-limit windows' percent used, by kind (five_hour, seven_day, spend_limit).
24async function usage($) {
25  try {
26    const u = await $.session.usage()
27    return { ctx: u.context.percent == null ? null : Math.round(u.context.percent), limits: Object.fromEntries(u.rateLimits.map((r) => [r.kind, r.percentUsed])) }
28  } catch {
29    return { ctx: null, limits: {} } // usage unavailable
30  }
31}
32
33export function register(on, options = {}) {
34  let ttlMs = TTL_MS['5m']
35  let tick
36  let warned // the turn whose cache expiry was announced
37  let expired // the turn whose cache expiry was announced as past
38
39  on('session.start', async ($, e, next) => {
40    try {
41      ttlMs = TTL_MS[(await $.settings.read()).promptCacheTtl] ?? TTL_MS['5m']
42    } catch {
43      // settings unreadable: the API default
44    }
45    // A toast and a sound a minute before the prompt cache expires and when it has, once per turn.
46    tick?.cancel()
47    tick = $.clock.every(1000, async () => {
48      const l = await read($, last)
49      if (!l) return
50      const left = ttlMs - ((await $.clock.now()) - l.at)
51      if (left > 0 && left <= 60_000 && warned !== l.at) {
52        warned = l.at
53        $.ui.toast(`Prompt cache expires in ${clock(left)}`)
54        await clip($, options.soundCacheExpiring, options.soundGain)
55      }
56      if (left <= 0 && expired !== l.at) {
57        expired = l.at
58        $.ui.toast('Prompt cache expired')
59        await clip($, options.soundCacheExpired, options.soundGain)
60      }
61    })
62    return next(e)
63  })
64
65  on('session.end', async ($, e, next) => {
66    if (e.reason === 'clear') await update($, last, () => null)
67    return next(e)
68  })
69
70  on('turn.complete', async ($, e, next) => {
71    const keys = new Set()
72    // Limits, context and the cache clock follow the main loop only.
73    if (!e.agentId && e.usage) {
74      const at = await $.clock.now()
75      const u = await usage($)
76      const a = alerts(u.ctx, u.limits, (await read($, last))?.alert)
77      await update($, last, () => ({ at, alert: a.now }))
78      for (const x of a.alerts) {
79        $.ui.toast(x.text)
80        keys.add(x.key)
81      }
82    }
83    const notice = options.notify === false ? null : turnNotice(e, options.notifyAfterSeconds)
84    if (notice) await notify($, notice)
85    const done = turnSound(e, options.doneAfterSeconds)
86    if (done) keys.add(done)
87    // One clip per distinct sound: two limits crossing together play it once.
88    for (const name of new Set([...keys].map((k) => options[k]))) await clip($, name, options.soundGain)
89    return next(e)
90  })
91
92  // A question waits for the person: notify and play as it is asked.
93  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
94    if (options.notify !== false) await notify($, `Claude is asking: ${e.input?.questions?.[0]?.question ?? 'a question'}`)
95    await clip($, options.soundNeedsInput, options.soundGain)
96    return next(e)
97  })
98
99  // A permission prompt waits for the person: play unless a settings hook beneath decided it. A question plays above.
100  on('classic.PermissionRequest', async ($, e, next) => {
101    const r = await next(e)
102    if (!r?.decision && e.tool_name !== 'AskUserQuestion') await clip($, options.soundNeedsInput, options.soundGain)
103    return r
104  })
105
106  // The hud plugin's PR state: checks that finish while the person is elsewhere. Nothing without hud.
107  on('state.set', { plugin: 'hud', key: 'repo' }, async ($, e, next) => {
108    const notice = options.notify === false ? null : ciNotice(e.previous, e.value)
109    if (notice) await notify($, notice)
110    return next(e)
111  })
112}
113
hooks/alerts.mjs 71 lines
1// Which alerts and sounds a turn raises, free of engine imports so node can test them. Pure: facts in, alerts out.
2export const TTL_MS = { '5m': 300_000, '1h': 3_600_000 }
3
4export const clock = (ms) => `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`
5
6// The limits that raise a toast and a sound: the sound setting, the label, and the percent each reads.
7export const LIMITS = [
8  { key: 'soundLimit5h', kind: 'five_hour', label: '5h window' },
9  { key: 'soundLimit7d', kind: 'seven_day', label: '7d window' },
10  { key: 'soundLimitSpend', kind: 'spend_limit', label: 'Spend limit' },
11]
12const levelOf = (pct) => (pct >= 100 ? 100 : pct >= 90 ? 90 : pct >= 80 ? 80 : 0)
13
14// What a turn's end raises: the context and each limit crossing 80, 90 and 100. `before` holds the levels raised
15// already, so a level is announced once and again only after it was left. Each alert names the sound setting it plays.
16export function alerts(ctx, limits, before = {}) {
17  const now = { ctx: levelOf(ctx ?? 0) >= 90 ? 90 : levelOf(ctx ?? 0) }
18  const out = []
19  if (now.ctx > (before.ctx ?? 0)) out.push({ key: 'soundContext', text: now.ctx === 90 ? `Context ${ctx}%: compact now` : `Context ${ctx}%: compact soon` })
20  for (const { key, kind, label } of LIMITS) {
21    const pct = limits[kind]
22    now[kind] = pct == null ? 0 : levelOf(pct)
23    if (now[kind] > (before[kind] ?? 0)) out.push({ key, text: now[kind] === 100 ? `${label} reached` : `${label} ${Math.round(pct)}% used` })
24  }
25  return { alerts: out, now }
26}
27
28// The sound setting a finished turn plays, if any: a subagent's, an interrupt, a refusal, an error, or an answer
29// that took at least `doneAfterSeconds`.
30export function turnSound(e, doneAfterSeconds = 0) {
31  if (e.agentId) return 'soundSubagent'
32  if (e.reason === 'aborted') return 'soundAborted'
33  if (e.reason === 'refusal') return 'soundRefused'
34  if (e.reason === 'error') return 'soundError'
35  return e.durationMs >= doneAfterSeconds * 1000 ? 'soundDone' : null
36}
37
38// A minute-and-seconds duration for a notification: 45s, 2m 3s.
39const took = (ms) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`)
40
41// The system notification a finished main-loop turn raises, if any: an answer after at least `afterSeconds`, or a
42// turn that ended badly. An interrupt is the person's own doing and raises none.
43export function turnNotice(e, afterSeconds = 60) {
44  if (e.agentId || e.reason === 'aborted') return null
45  if (e.reason === 'refusal') return 'The model refused the turn'
46  if (e.reason === 'error') return 'The turn ended on an API error'
47  return e.durationMs >= afterSeconds * 1000 ? `Turn finished in ${took(e.durationMs)}` : null
48}
49
50// The notification a change of the branch's PR checks raises (from the hud plugin's repo state), if any: checks that
51// were running and now passed or failed, on the same PR.
52export function ciNotice(previous, value) {
53  const [a, b] = [previous?.pr, value?.pr]
54  if (!a || !b || a.number !== b.number || a.ci !== 'pending' || b.ci === 'pending' || !b.ci) return null
55  return b.ci === 'fail' ? `Checks failed on #${b.number}` : `Checks passed on #${b.number}`
56}
57
58// The plugin.json defaults, for the harnesses without per-plugin settings (pi, Codex, opencode): the clip per alert
59// or turn ending, and for a prompt waiting on the person.
60export const CLIPS = { soundContext: 'alert', soundDone: 'done', soundAborted: null, soundRefused: 'error', soundError: 'error', soundSubagent: 'tick', soundNeedsInput: 'chime' }
61
62// What a finished run raises there: the clips to play, the notices to show, and the context levels raised so far
63// (`before` for the next run).
64export function settle({ durationMs, aborted, error, agentId, pct, before }) {
65  const e = { durationMs, agentId, reason: aborted ? 'aborted' : error ? 'error' : 'done' }
66  const { alerts: raised, now } = alerts(pct, {}, before)
67  const clips = [CLIPS[turnSound(e)], ...raised.map((a) => CLIPS[a.key])].filter(Boolean)
68  const notices = [turnNotice(e), ...raised.map((a) => a.text)].filter(Boolean)
69  return { clips: [...new Set(clips)], notices, now }
70}
71
types/index.d.ts 8 lines
1declare module 'claude-code' {
2  interface PluginState {
3    sounds: {
4      last: { at: number; alert: Record<string, number> } | null
5    }
6  }
7}
8