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

sounds pluginToasts 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.
| Setting | Plays on | Default |
|---|---|---|
soundLimit5h, soundLimit7d, soundLimitSpend | The window crossing 80%, 90%, 100% (with a toast) | warn |
soundContext | Context crossing 80%, 90% (with a toast) | alert |
soundCacheExpiring, soundCacheExpired | A minute before the prompt cache expires, and when it has (with a toast) | ping, warn |
soundDone | A turn answered, at least doneAfterSeconds long (default 0) | done |
soundAborted, soundRefused, soundError | A turn interrupted (also a message sent mid-turn), refused, or ended by an API error | off, error, error |
soundSubagent | A subagent's turn ended | tick |
soundNeedsInput | A permission prompt or a question waiting on you (also Codex and opencode) | chime |
notify | System 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.
/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 checkouthooks/mod.mjs 113 lines1// 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}
113hooks/alerts.mjs 71 lines1// 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}
71types/index.d.ts 8 lines1declare module 'claude-code' {
2 interface PluginState {
3 sounds: {
4 last: { at: number; alert: Record<string, number> } | null
5 }
6 }
7}
8